制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A WordPress + WooCommerce plugin for running virtual balloon races as fundraisers. Supporters buy a balloon entry for a race, customise it (name, colour, helium strategy, and optional paid extras), and after launch the balloons drift across a live map using real wind data. A leaderboard ranks balloons by how far they get from the launch point (or, per race, by distance travelled).
lumaha-virtual-balloon-race (code prefix lvbr)virtual-balloon-race-<version>.zip from the Releases page on GitHub.Updating: from version 1.11.0, new GitHub releases show up as normal plugin updates (Dashboard → Updates and the Plugins screen, with View details showing the release notes), and Enable auto-updates on the Plugins screen installs them by itself. WordPress checks about twice a day. Earlier versions: upload the newer zip the same way as installing; WordPress offers Replace current with uploaded. Either way, races, balloons and settings are kept, and any database upgrade runs on the next page load. If the site uses LiteSpeed Cache, run LiteSpeed Cache → Purge All afterwards.
Upgrading to 2.0.0: the plugin's internal names changed from vbr to lvbr (WordPress.org needs a prefix of at least four characters) and it's now called Lumaha Virtual Balloon Race. The update moves everything over on the next page load: tables, settings, order and product data, the entry product type, shortcodes in pages and widgets, and scheduled updates. Things outside the database need changing by hand: shortcodes typed into theme files or page builders ([balloon_map] → [lvbr_map], and the same for the others), custom code using the vbr_ hooks or /vbr/v1/ REST routes (now lvbr_ and /lvbr/v1/), CSS aimed at .vbr- classes (now .lvbr-), email template overrides in a theme (now lvbr-balloon-launched.php and lvbr-balloon-results.php) and server cron jobs running wp vbr tick (now wp lvbr tick). Sites upgraded from 1.x keep external services and sponsor counting switched on.
Deleting the plugin keeps all race data unless Settings → Data → Delete all race data when the plugin is deleted is ticked. Its scheduled jobs always stop.
Translations: languages/lumaha-virtual-balloon-race.pot is the translation template (refreshed in every release zip; the text domain is lumaha-virtual-balloon-race). Translate it with Poedit or Loco Translate, or put lumaha-virtual-balloon-race-<locale>.mo in wp-content/languages/plugins/.
Moving from a hand-copied folder (e.g. wp-content/plugins/VirtualBaloonRace/): the zip installs to wp-content/plugins/virtual-balloon-race/, which WordPress treats as a different plugin, so don't run both. Deactivate the old one, delete it (the plugin has no uninstall routine, so this removes only its files; races, balloons, settings and pages stay in the database), then upload and activate the zip.
Balloon Race → Settings holds the rules shared by every race:
| Setting | What it does |
|---|---|
| Flight mode | How quickly balloons lose lift, and so how far a race goes (each race can choose its own): Drift (true to life: fast on launch day, then lands within about a day and a half), Soar (default: slows gradually, speed halves every 36 hours of flight), Jetstream (never slows, today's long-distance behaviour). The Settings cards estimate the leader's distance after 1 day, 3 days and a week at the current base speed |
| Auto-update interval | How often balloons move: off, 5 min, 30 min, hourly, 6-hourly or daily. This, speed and bursts are defaults: each race can change them under Movement on its race screen |
| Base speed, helium multipliers, random variance | How far balloons travel per hour. Max Height is faster, Eco-Glide slower |
| Default launch point | Pre-filled when you create a race. Search a place or click the map |
| Live wind / per-balloon wind / wind influence | Real wind from Open-Meteo (free, no key). Per-balloon wind makes paths split apart as balloons spread |
| Default heading | Direction balloons travel if wind data isn't available |
| Burst chances | Max Height burst chance per day, plus storm bursts above a wind threshold (Eco-Glide is immune) |
| Map and leaderboard | Default zoom, how often the public map refreshes, trail length, and leaderboard entries per page (10 by default; visitors can change it) |
| Maps, weather and place search | Allow external services: off on new sites until you allow it, here or from the prompt on the plugin's screens. Until then maps show balloons without a background map, balloons follow the default heading and place search is hidden. Map tiles: OpenStreetMap (default; free, light use only), MapTiler or Stadia Maps (with a map API key), or a custom tile URL with its attribution. Place search: Nominatim (default), MapTiler (same key, needs MapTiler tiles) or off. Open-Meteo API key for commercial use (wind then comes from Open-Meteo's customer API; the key stays on the server). A keyed provider without a key falls back to OpenStreetMap. Map keys are visible in browsers, so restrict them to the site's address |
| Shop add-ons | Turn extras on or off and set their prices |
| Demo race | Create a demo race: 30 made-up balloons, launched three days ago and flown on real wind, ending in four days, so the map and leaderboard have something to show. Demo entrants have no email addresses or orders, so nobody is emailed. Keep it running (on by default): once a day, a demo race with less than a day left is replaced by a fresh one with as many balloons. Remove demo races deletes them. Also wp lvbr demo [--balloons=30] and wp lvbr demo --remove |
| Data | Count sponsor views and clicks (off by default; anonymous totals kept on the site). Delete all race data when the plugin is deleted (off by default). Deleting the plugin from Plugins then removes its races, balloons, teams and settings; pages, products, orders and uploaded logos are kept either way. Deactivating never deletes anything |
| Teams | Off by default. Enable teams, the default scoring (average distance, total distance or best balloon) and the default teams (name and colour) that new races start with |
Timing: WordPress only runs scheduled jobs when someone visits the site. On a quiet site, updates can arrive late, though balloons still catch up on the next run. For on-time updates, add a real server cron that runs
wp lvbr tickevery few minutes.
Balloon Race → Races → Add race
| Field | Meaning |
|---|---|
| Name | Shown on the shop, map and leaderboard |
| Entries open | When people can start buying. Leave blank to open straight away |
| Launch | Entries close and balloons start moving |
| Ends | Optional. Balloons stop and results become final. Leave blank to finish by hand |
| Launch point | Where balloons start. Search or click the map |
| Entry limit | Optional. The most balloons the race can have. Paid balloons take places, and so do unpaid orders still in progress (bank transfer on hold, or awaiting payment from the last day). When full, the shop shows "Sorry, … is sold out", and baskets can't hold more entries than there are places left. With 10 or fewer left, the product page shows "Only N places left!". Refunded or cancelled entries free their places. Blank means no limit |
| Flight mode | Use the default from Settings, or pick Drift, Soar or Jetstream for this race |
| Movement | Optional, folded away: this race's own update interval, base speed, helium multipliers, variance and burst chances. Blank boxes use the Settings value (shown in grey), so a short demo and a week-long race can run side by side |
| Winner decided by | Distance from launch (default): straight-line distance from where the balloon started, as in real balloon races. Distance travelled: the whole trail added up, so wandering balloons score more. Team scores use the same measure |
A race goes through four stages on its own:
| Stage | Shop | Balloons |
|---|---|---|
| Upcoming | Closed ("entries open on …") | — |
| Entries open | Open | Wait at the launch point |
| Flying | Closed | Move with the wind on every update |
| Finished | Closed | Stopped; final leaderboard |
Each race's page has Launch now and Finish now buttons for event-day control. A race can only be deleted while it has no balloons.
You can schedule several races. Usually only one runs at a time, but overlapping races work too.
With Enable teams ticked in Settings, each race has its own teams on its race screen: new races start with the default teams, and you can rename, recolour, add or remove them (a team with balloons can't be removed). Add default teams fills a race that has none, and Team scoring can override the default for that race. A race with no teams runs without them.
When a race has teams, buyers must pick one. Team standings appear on the tracking page, the leaderboard and map show each balloon's team, and the map can be filtered to one team. Scores: average and total use every balloon in the team (burst balloons count at the distance they reached); best is the team's furthest balloon. Switching teams off hides them everywhere but keeps them.
The race screen's Prizes panel lists what's up for grabs: 1st to 5th place, best team, a random draw, or your own label (e.g. "Furthest Eco-Glide"), each with a description and an optional Donated by sponsor (one of this race's saved sponsors). Prizes show:
[lvbr_prizes], part of the default page; pages still on an earlier default get it on upgrade, edited pages are left alone),Sponsor links are marked rel="sponsored" and open in a new tab.
Each race can have its own sponsors (never site-wide), added on the race screen's Sponsors panel: name, website, logo (from the Media Library) and tier. Nothing is sold through the shop.
| Tier | Ticks to start with |
|---|---|
| Headline | Presented by, hot-air balloon on the map, emails, sponsor wall |
| Gold | Hot-air balloon on the map, emails, sponsor wall |
| Silver | Sponsor wall |
| Supporter | Sponsor wall |
Change any sponsor's ticks (also: launch flag, map badge, leaderboard strip). Presented by, launch flag, map badge and leaderboard strip have one spot per race: on a clash the higher tier keeps it (between equal tiers, the one higher in the list), and the save notice says who lost it. Deleting a race removes its sponsors; their logos stay in the Media Library. Where they appear:
[lvbr_sponsors], by tier (Headline large, Gold and Silver with logos, Supporters as names); part of the default tracking pageEvery sponsor link opens their website in a new tab and is marked rel="sponsored". Logos come from your Media Library; nothing is loaded from other sites.
Views and clicks (once Count sponsor views and clicks is ticked in Settings → Data). Each sponsor's card shows how often they were shown on the race pages and how often their links were clicked, and Download views and clicks (CSV) gives one row per sponsor and placement, handy for thanking sponsors after the race. The visitor's browser reports these (race pages are usually page-cached, so the server rarely sees a visit): one small request per page with the placements shown, and one per click. Only running totals are kept: no cookies, no IP addresses, nothing about who saw or clicked what. Each placement counts once per page view; emails and visitors without JavaScript (most bots) aren't counted. The numbers are a guide, not an audit: the reporting address is public.
A fresh install creates a ready-to-sell Balloon Race Entry product (£3, published, any race taking entries) as soon as WooCommerce is active. It can only be bought while a race is taking entries, and it isn't listed in the shop, categories, search, product blocks, the [products] shortcode or the sitemap until a race is planned for it (upcoming or taking entries; for a product locked to one race, that race), so it's safe to leave published. Its own page still opens, saying no race is taking entries yet (or when entries open), so a link shared early still works. Change its name, price, description and picture like any product. Upgrades never add a product. Settings → Pages and products lists your entry products and offers Create entry product if there are none.
To sell a particular race separately: open the race (Balloon Race → Races → Edit) and click Create entry product. This makes a draft product locked to that race and opens it so you can set the price and publish.
Or create one by hand:
The race's edit screen lists every product that sells its entries.
When no race is taking entries, the product can't be added to the basket and shows when entries next open. If a race launches while an entry for it is still in someone's basket, the entry is removed with a message.
Upgrading from 1.2: products marked with the old "Balloon Entry" tick box or
balloon-entrytag are converted to the new type automatically. Products that weren't simple products (e.g. variable) can't be converted; a notice in admin lists them.
On balloon entry products, the product images are replaced by a live balloon preview: a balloon on a sky background that follows the buyer's choices (colour, uploaded picture, balloon name, team and helium strategy). To keep the normal product images, add add_filter( 'lvbr_product_preview', '__return_false' );.
Buyers fill in:
Entries are sold one balloon per basket line: there's no quantity box on the product page or in the basket, because each balloon has its own name, colour and picture. After adding a balloon, the buyer sees "“Sky Rocket” is in your basket" with Design another balloon and View basket; the form keeps their race, team and colour and clears the name for the next one. Identical balloons can still be added as separate lines.
A balloon is created when its order reaches Processing or Completed.
Activating the plugin creates a Balloon Race page (/balloon-race/) with the live map, team standings (when teams are on), the leaderboard and a list of races. It's ordinary page content (three shortcode blocks), so you can edit it, add text or rearrange it.
Balloon Race → Settings → Pages:
One page serves every race. /balloon-race/ shows the current race (the one flying, or else the next one taking entries, or else the next upcoming one, or else the last one finished). /balloon-race/?race=summer-2026 shows a particular race, and the race list links to each one. Links like /balloon-race/?race=summer-2026&balloon=12 open with that balloon selected.
Logged-in customers also get My Account → My balloons: each balloon they've entered, in every race, with its position, distance and a Track link.
You can still build your own pages:
[lvbr_map]
[lvbr_leaderboard]
[lvbr_races]
| Shortcode | Attributes |
|---|---|
[lvbr_map] | race = ID, slug or current. Without it, the page's ?race= is used, else the current race. height (default 520px). list="no" hides the side list |
[lvbr_leaderboard] | race (as above). per_page: entries per page (default from Settings, 10; all for everything; limit is the older name). map_url: where names link (default: the tracking page) |
[lvbr_prizes race="…"] | The race's prizes as cards, with "Donated by" links (nothing when it has none) |
[lvbr_sponsors race="…"] | The race's sponsor wall, by tier (nothing when it has none) |
[lvbr_teams] | Team standings: rank, team, balloons, score, leading balloon. race (as above), title. Shows nothing when teams are off or the race has none |
[lvbr_races] | Links to every race on the same page. title (default "All races"). min (default 2): hidden when there are fewer races |
On the map, clicking a balloon (or its name in the list or leaderboard) highlights its trail and fades the others. Click the map background to clear the highlight.
lvbr_match_balloons_by_email filter.GET /lvbr/v1/leaderboard), so a race of thousands still loads quickly. Search matches the same things either way: balloon name, public owner name (first name and initial) and team. The map is lighter too: above 300 balloons it sends only the top 25 trails plus the viewer's own, and loads any other balloon's trail when it's clicked. A 5,000-balloon race drops from about 2.3 MB to 150 KB (gzipped), and refreshes that find nothing new are answered "not modified".Balloons follow their orders:
Customers see withdrawn balloons in My Account → My balloons with the reason, and get no tracking link for them.
Two WooCommerce emails go to everyone with a balloon in a race (one email per person, listing all their balloons; withdrawn balloons are left out):
Switch either off, or change its subject, heading and closing text, in WooCommerce → Settings → Emails. They use the shop's email header, footer and colours. Each race's screen shows whether its emails have gone out and to how many people. Emails are queued with WooCommerce's Action Scheduler and sent in the background, so large races don't slow the site. The plugin checks for launches and finishes hourly, after every movement update, and straight away on Launch now / Finish now. Upgrading to 1.8.0 marks races that have already launched or finished as emailed, so nothing old is sent. Theme overrides: copy templates/emails/lvbr-balloon-launched.php (or lvbr-balloon-results.php, and the plain/ versions) to yourtheme/woocommerce/emails/.
Balloon Race → Leaderboard (pick the race at the top):
virtual-balloon-race.php Bootstrap: constants, includes, activation, WP-CLI
includes/
class-lvbr-install.php Schema (dbDelta), version upgrades, Multisite installs
class-lvbr-races.php Race model: stages, lookups, save/delete, time helpers
class-lvbr-balloons.php Balloon table helpers, colours/strategies, trails, asset versions
class-lvbr-settings.php Settings screen + LVBR_Settings::get(); registers Leaflet
class-lvbr-cron.php Scheduling (Action Scheduler or WP-Cron) and the tick
class-lvbr-weather.php Open-Meteo wind, batched by ~11 km grid cell, cached in transients
class-lvbr-geo.php Great-circle destination / distance
class-lvbr-map.php REST API + [lvbr_map]
class-lvbr-frontend.php [lvbr_leaderboard], [lvbr_races] + frontend assets
class-lvbr-pages.php Tracking page: create on install, Settings → Pages, tracking_url()
class-lvbr-account.php My Account → My balloons
class-lvbr-admin.php Leaderboard admin page (per race)
class-lvbr-admin-races.php Races admin page
class-lvbr-product-type.php "Balloon race entry" product type, its admin tab, 1.2 → 1.3 product migration
class-lvbr-product-balloon-entry.php The product class (always virtual)
class-lvbr-woocommerce.php Balloon product fields, cart, order → balloon rows
class-lvbr-race-entries.php Race choice, purchasability, basket guard, race_id on balloons
class-lvbr-teams.php Teams: per-race lists, scoring, standings, the team editor
class-lvbr-flight-modes.php Flight modes (Drift, Soar, Jetstream): lift decay, landing, Settings cards
class-lvbr-demo.php Demo races (Settings → Demo race, wp lvbr demo)
class-lvbr-housekeeping.php Daily clean-up of abandoned logo uploads; basket check for removed logos
class-lvbr-race-emails.php Launch and results emails: when to send, who to, background queue
emails/ The two WooCommerce email classes (templates in templates/emails/)
class-lvbr-order-sync.php Withdraws / reinstates balloons when orders are refunded, cancelled or fail
class-lvbr-team-entries.php Team choice on the product page → basket → order → team_id on balloons
class-lvbr-monetization.php Paid add-ons: strategy fee, custom launch, logo upload
class-lvbr-product-preview.php Live balloon preview in place of the product images
class-lvbr-cli.php wp lvbr …
assets/vendor/leaflet/ Leaflet 1.9.4 (BSD-2-Clause), bundled: no third-party CDN
assets/js/balloon.js Balloon drawing (SVG) shared by the map and product page
assets/js/map.js Public Leaflet map
assets/js/product.js Product page launch picker + logo preview
assets/js/admin-settings.js Launch-point picker (Settings and race form)
assets/js/admin-teams.js Team list editor (Settings and race form)
{prefix}lvbr_races: one row per race. All times are stored in UTC. Stages are calculated from the clock and never stored.{prefix}lvbr_teams: one row per team, with race_id, name, color, sort. Races have team_scoring ('' = the Settings default).class-lvbr-flight-modes.php): each balloon stores flight_hours; a tick converts its hours into full-speed-equivalent hours by integrating the mode's half-life decay over the tick (so the update interval doesn't change the result), and Drift stops balloons after 36 flight hours. Races have flight_mode ('' = the Settings default).distance_km is the whole trail added up; from_start_km is the straight-line distance from the balloon's start (its custom launch point, else the race's), updated on every tick. Races have ranking ('' or from_start = distance from launch, travelled = distance travelled), and leaderboards, team scores and REST rank by that column.{prefix}lvbr_balloons: one row per balloon, with race_id, team_id, current latitude/longitude, start_lat/start_lng, trail_json (the most recent [lat, lng] points, capped by the trail-length setting), logo_attachment_id, has_custom_start, distance_km and is_burst.lvbr_settings option. Tracking page: lvbr_tracking_page_id. Last tick per race: the lvbr_tick_summaries option.plugins_loaded whenever lvbr_db_version is older than LVBR_DB_VERSION. Upgrading from 1.1 creates a first race and moves existing balloons into it. Steps that need WooCommerce or rewrite rules (product conversion, default pages, flushing rewrites) are queued in lvbr_pending_setup and run on the next init.For each Flying race, a scheduled tick simulates the time since that race's last tick, counting from launch, stopping at the end time, and capped at 3× the interval. Each race uses its own interval and movement settings where it has them: the job runs at the shortest interval any unfinished race needs, and a race only moves when its own interval is due (races left on "Off" are manual only). For each balloon still flying:
1 − (1 − daily)^(days).Uncached grid cells are fetched in batches (up to 50 per request), and a failed lookup is remembered for 2 minutes. A lock stops two ticks running at once.
| Route | Returns |
|---|---|
GET /wp-json/lvbr/v1/races | { current, races: [{ id, name, slug, status, status_label, entries_open, launch_at, end_at, launch, entry_limit, places_left }] } (entry_limit and places_left are null when there's no limit) |
GET /wp-json/lvbr/v1/leaderboard?race=<id|slug>&page=&per_page=&search=&mine=&map_url= | One page of a race's leaderboard as rendered table rows: { html, total, page, pages, per_page }. Used by big races (see Leaderboard paging). search matches balloon name, public owner name and team; mine=1 limits to the logged-in viewer's balloons (send the wp_rest nonce) |
GET /wp-json/lvbr/v1/balloons?race=<id|slug|current> | { race, launch, updated_at, next_at, balloons: [{ id, rank, name, owner, color, hex, helium, distance, burst, lat, lng, logo, logo_fit, team, trail, from_start, travelled }], teams: [{ id, rank, name, color, balloons, score, leader }], scoring, ranking }. distance is the measure the race ranks by. teams is empty and team null when the race doesn't use teams. Above 300 balloons (filter lvbr_map_all_trails_up_to), trail is only sent for the top 25 and for IDs in trails=1,2,… (up to 50), and trails is "some" (else "all"). Sends an ETag and answers If-None-Match with 304 |
GET /wp-json/lvbr/v1/trail?race=<id|slug>&id=<balloon id> | { id, trail }: one balloon's trail, for big races where the map loads it on demand |
Emails and order details are never included, and owners are first name + initial. Responses are sent with no-cache headers, including LiteSpeed's.
wp lvbr tick # run due updates for every flying race (use from a system cron)
wp lvbr tick --race=<id|slug> --hours=6 # simulate hours for one race
wp lvbr races # list races, stages and balloon counts
wp lvbr status # schedule + last tick per race
wp lvbr demo [--balloons=30] # create a demo race with made-up balloons
wp lvbr demo --remove # remove demo races
| Hook | Type | Use |
|---|---|---|
lvbr_is_balloon_product | filter | Treat other products as balloon entries ($is_entry, $product_id) |
lvbr_product_data_panel | action | Add fields to the product's Balloon race tab |
lvbr_new_balloon_row | filter | Change a balloon row before insert ($row, $order_item, $order) |
lvbr_helium_option_label | filter | Helium option text on the product page |
lvbr_product_fields_before / lvbr_product_fields_after | action | Add fields inside the balloon box |
lvbr_color_hex | filter | Map colours for balloon colour names |
lvbr_default_entry_product | filter | Name, price and descriptions of the product a fresh install creates (array( name, price, short_description, description )) |
lvbr_product_preview | filter | Return false to keep the product images instead of the live balloon preview ($show, $product) |
lvbr_match_balloons_by_email | filter | Return true to also give users the balloons whose order email matches their account or billing email ($match, $user). Off by default, as WooCommerce doesn't verify those emails |
lvbr_leaderboard_server_paging_from | filter | From how many balloons the leaderboard is paged and searched on the server (default 500) |
lvbr_map_all_trails_up_to | filter | Up to how many balloons the map gets every trail (default 300); above it, the top 25, the viewer's and the selected balloon's |
lvbr_public_owner_name | filter | Public owner name, after shortening to first name + initial ($name, $balloon) |
lvbr_after_tick | action | Runs after a race moves ($summary) |
Every pull request and push to main runs:
.github/workflows/php_check.yml). Rules are in phpcs.xml.dist; errors and warnings both fail the check. Queries on the plugin's own tables carry a targeted phpcs:ignore with the reason. Run it locally with composer install && composer phpcs.phpstan.neon.dist). Run it locally with composer install then composer phpstan. composer.json holds development tools only; the plugin itself has no Composer dependencies, and vendor/ isn't committed. phpstan-baseline.neon is empty; if the level is raised, record existing findings there so only new ones fail..github/workflows/smoke_test.yml): installs the latest WordPress and WooCommerce, then tests/smoke/run.sh checks both activation orders (plugin before and after WooCommerce), the default page and product, a race end to end (purchasability, order → balloon, a tick, REST, teams), refunds and cancellations (line refund, refund deleted, goodwill refund, cancel and reopen, hand withdrawal, failed payment), the launch and results emails (one per person, withdrawn balloons left out, sent once, switched off in WooCommerce), housekeeping (abandoned logos, the basket check), entry limits (paid and unpaid places, sold out, basket limits, places freed), deleting the plugin with "Delete all race data" off and on and that the plugin logs no PHP errors. To run it by hand: set WP_PATH to a throwaway WordPress with WooCommerce and this plugin (as plugins/virtual-balloon-race) copied in, then bash tests/smoke/run.sh. It empties that site's database.readme.txt is the plugin's WordPress.org listing (description, FAQ, External services, changelog). Keep Stable tag and the changelog in step with the version when releasing.
Donations go to https://buymeacoffee.com/lumaha: a Donate link on the Plugins screen and a short "Support this plugin" note at the foot of Settings (LVBR_Support::DONATE_URL in includes/class-lvbr-support.php, or the lvbr_donate_url filter; empty hides both), Donate link: in readme.txt, and GitHub's Sponsor button (.github/FUNDING.yml).
Version:) and LVBR_VERSION in virtual-balloon-race.php (and LVBR_DB_VERSION if the schema changed). Merge it.main and push the tag: git tag v1.7.0 && git push origin v1.7.0..github/workflows/release.yml) checks the tag matches the plugin version, builds the zip with .github/scripts/build-zip.sh and publishes a GitHub release with the zip attached and notes listing the merged pull requests.includes/class-lvbr-github-updater.php reads the latest release through GitHub's API, cached for 12 hours; the plugin header's Update URI sends WordPress's update check there). A release must stay non-draft, non-prerelease and keep its virtual-balloon-race-<version>.zip asset, or it isn't offered..distignore lists the files left out of the zip (tests, CI, Composer and PHPStan files, docs/). Every pull request also builds the zip and offers it as a download (Checks → Release → Artifacts, kept 7 days), and the fresh-install smoke test installs from that same zip. Build one locally with bash .github/scripts/build-zip.sh (output in dist/).
WordPress.org listing images (.wordpress-org/, not in the zip): screenshot-1.png…screenshot-5.png in the order of readme.txt's Screenshots section, banner-772x250.png / banner-1544x500.png and icon-128x128.png / icon-256x256.png. WordPress.org puts the plugin's name over the bottom left of the banner, so keep that area clear. The deploy to WordPress.org uploads this folder as the listing's assets.
WordPress.org build: bash .github/scripts/build-zip.sh --wporg makes dist/virtual-balloon-race-<version>-wporg.zip without the GitHub update checker and the Update URI header, as WordPress.org requires (its plugins may only update from WordPress.org). Use it for the first submission; its folder, dist/wporg/virtual-balloon-race/, is what gets deployed to WordPress.org's SVN.
Any WordPress with WooCommerce will do (for example wp-env or the official Docker images). Copy the plugin in with the same exclusions as the zip:
rsync -rlt --delete --exclude-from=.distignore --exclude dist ./ /path/to/wp-content/plugins/virtual-balloon-race/
wp lvbr status
wp lvbr demo --balloons=10
scp -r into an existing folder creates a nested copy; use rsync. The fresh-install smoke test runs locally too: WP_PATH=/path/to/wordpress bash tests/smoke/run.sh (see the comments at its top).
How it works and why: docs/design.md. Before a real race: docs/race-day-checklist.md. Plans, bugs and ideas are tracked in GitHub Issues (milestones: Ready for wider use, WordPress.org listing). Not built yet: