Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證A tick-based resource management game for phones. No ads, no in-app purchases, no energy meters, no paid speed-ups.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Ports Ahoy!
A tick-based resource management game for phones. You run a small trading port
in a cold archipelago: extract raw goods, refine them through production chains,
weather what the sea throws at you, and decide how much of the dark trade you
want a part of.
No ads. No in-app purchases. No energy meters. No paid speed-ups.
That is a design constraint, not a marketing line, and it is enforced
mechanically and by test:檢視完整 README ↗A tick-based resource management game for phones. You run a small trading port in a cold archipelago: extract raw goods, refine them through production chains, weather what the sea throws at you, and decide how much of the dark trade you want a part of.
No ads. No in-app purchases. No energy meters. No paid speed-ups.
That is a design constraint, not a marketing line, and it is enforced mechanically and by test:
| Free-to-play pattern | What this game does instead |
|---|---|
| Sell time skips on build timers | Fast-forward up to 4× is a free button |
| Cap offline progress, sell a bigger cap | There is no offline progress to cap. The world is frozen while you are away |
| Punish you for closing the app | Nothing happens at all while the app is closed — nothing seized, nothing eaten, nothing missed |
| Sell relief from a pressure meter | Notoriety has no coin path down and no passive decay — nothing to wait out |
| Gate content behind currency | The goal is a project you manufacture yourself |
| Sell a skip on a voyage | Nothing shortens a crossing. A better captain speeds up future voyages, never one already at sea |
The governing rule, asserted by test: nothing anywhere shortens a duration. Voyages are the only thing in the game that takes time, and no coin, item, building or action touches one. A voyage is also never a gate — your own quay stays open the entire time — so a wait is a choice you make for a better price.
The Flutter SDK is installed at ~/flutter.
export PATH=$HOME/flutter/bin:$PATH
cd ~/ports_ahoy
flutter run -d chrome # fastest iteration loop
flutter run -d <device-id> # a plugged-in Android phone (flutter devices)
flutter build web --release # what is currently tested
The quickest route is the web build over your own network:
cd ~/ports_ahoy
export PATH=$HOME/flutter/bin:$PATH
flutter build web --release
python3 -m http.server 8823 --bind 0.0.0.0 -d build/web
# then open http://<your-lan-ip>:8823 on the handset
One command, from anywhere:
sh ~/ports_ahoy/tool/build_apk.sh
It builds a signed release APK and copies it to
~/ports_ahoy_builds/PortsAhoy-<version>.apk, telling you the path, the size,
and whether it was signed with the upload key or fell back to debug signing.
Or by hand — note the cd, which is the part that matters:
cd ~/ports_ahoy
export PATH=$HOME/flutter/bin:$PATH
flutter build apk --release
# lands at build/app/outputs/flutter-apk/app-release.apk
A JDK and the Android SDK live under ~/android (no root, no Android Studio —
just the command-line tools).
What used to be written here was broken, in two ways. The block had no cd,
so following it exactly ran the build in whatever directory you were in and
Flutter answered "No pubspec.yaml file found." And it claimed Flutter was
already configured to find the SDK — which was true only inside the editor,
whose XDG_CONFIG_HOME points into a Flatpak directory. A normal terminal reads
~/.config/flutter/settings, and nothing had ever been written there, so the
ANDROID_HOME exports were load-bearing in one shell and useless in the other.
The config now lives in the real user config directory, which is why the short
form above works with no exports beyond PATH.
Verify a change:
cd ~/ports_ahoy
export PATH=$HOME/flutter/bin:$PATH
flutter analyze # clean
flutter test # 432 tests
dart run tool/balance_probe.dart # 8-seed headless pacing check
dart run tool/balance_probe.dart --dark # ...playing the dark trade
dart run tool/balance_probe.dart --charters=poor_soil # ...under a hardship
dart run tool/balance_probe.dart --hire # ...taking on officers
# how close the bot is to a real player's curve
dart run tool/balance_probe.dart --charters=a_full_purse,poor_soil --journal=/tmp/bot.pa1
dart run tool/calibrate.dart tool/reference_runs/human-*.pa1 /tmp/bot.pa1
Time runs in ticks; one tick is one game-hour, 24 ticks to a day. Production happens every tick. Food, wages and population settle at day end.
Extractors need no input (Forest Camp, Farm, Fishing Wharf, Flax Field, Mine). Workshops refine (Sawmill, Ropewalk, Cooperage, Weaver, Smithy). A building runs at the rate of its scarcest constraint — starved of input or out of storage space, it throttles rather than cheating.
The core tension is rope. Flax feeds the Ropewalk, and every coil that comes out either goes on the lighthouse or into a sail — the Weaver turns wool and rope into sailcloth, and the light wants both. So rope is the one thing you cannot make more of by staffing the shed that needs it.
That decision used to live one step upstream, when flax fed the Ropewalk and
the Weaver alike. Moving the Weaver onto wool did not delete it; it relocated
it onto a finished good, where spending it hurts more. A test
(rope is contested between the lighthouse and the sails) stops a future
balance pass collapsing it into a dominant choice.
Every resource has a price index that mean-reverts toward 1.0. Selling in bulk drives a price down for days; buying pushes it up. Diversifying beats optimising one chain. Events add exogenous shocks on top — a frozen sound bids planks up, a glut craters one trade, privateers in the lanes make every import dear. Shocks decay on their own once the event lifts.
The Import Berth is a shed whose input is coin: staff it and it spends coin per worker-hour to land raw cargo at market price plus a factor's cut.
Two rules are written into the source and must not be broken:
It only ever lands raws (timber, flax, ore) — never a finished good and never food. Every plank, rope, sailcloth and tool in the win condition still passes through a shed you built and staffed.
15 events across a 120-day, four-season year. Every event forewarns with an omen that states truthfully what is coming and for how long: duration, magnitude and target are all rolled once at draw time and stored, so a save reloaded mid-omen cannot reroll the outcome. Forewarning that lies is worse than none.
Guardrails, all tested:
The Grange is the third route to the light, and the only one that converts time into goods. Trade converts labour and the dark trade converts risk, and both pay the moment you act. A grange pays nothing on the day you build it and grows to +20% on every shed over twenty-four days of being worked — a bet on the run being long, not an upgrade everyone takes. The clock only runs while it is staffed, so hands off it stop the growth, and a second grange adds nothing: the bet is made once.
It unlocks behind a farm, costs two hands and 400 coin, and its fields visibly ripen from bare earth to deep green so the ramp is something you can watch rather than take on trust.
It was specified wrong, and the probe caught it. It lifted extractors only at first, on the reasoning that a grange husbands the land and does not stand over a saw. Measured, that made it a trap: the workshops are worker-limited rather than input-starved, so the extra raws piled up untouched while the grange's two hands came straight off finished-good output. The median win went from 100 days to 112 — slower, while working exactly as designed. That is the same mistake the dark trade made in paying for contraband in raws, and the lesson is the same one: the lighthouse asks for planks, tools, rope and sailcloth, so a route that does not move those does not move anything. Lifting every shed instead gives 96 days on a plain run and 106 under A Grander Light, against 100 and 116.
It was then overtuned, and a player caught that. At +35% a port drowned: "my warehouse is filling quick and there's no pressure of being low on supplies". It compounds harder than the number suggests, because only outputs are scaled and inputs are not — an extractor makes 35% more raw and the workshop downstream turns the same raw into 35% more goods, so a two-stage chain lands near 1.8x while the raws it eats never rise. Cutting the ceiling to 20% and the ramp to 24 days holds the pacing and gives the middle of the game its scarcity back.
Three sheds keep animals, and all three ripen the way the Grange does — worth a quarter of themselves when built, full strength weeks later.
| Shed | Animal | Gives | Ramp |
|---|---|---|---|
| Pasture | Sheep | Wool, meat | 30 days |
| Byre | Cows | Milk, cheese, meat | 28 days |
| Hen House | Chickens | Eggs, meat | 14 days |
They all eat grain, which is what keeps them honest: the farm stops being a straight line to food and becomes a decision about what the harvest is for.
It is not a food chain. Wool goes into sailcloth and cheese onto the bill — that is where the hands are repaid. The meat, milk and eggs exist to hand back the food value the feed took out, so the herds never become a second town competing with the first for the harvest. That target is sharp enough to test: whatever the animals eat, they return in food value.
A Bakery turns grain and a little egg into bread worth three person-days a loaf. It is a grain multiplier rather than a food source — it makes the harvest stretch, which is what a port whose herds eat grain actually needs — and it is the one new shed with nothing on the bill, because its payoff is not coin.
The port has an opinion. Happiness answers to four things — whether people are fed well or merely fed, crowded or not, paid or not, and whether they are living in a smuggler's port — and it has teeth: unhappy hands work worse, a grim harbour fills up about sixty percent slower than a content one, and a miserable one loses people.
There is no relief for sale. That is the fourth row of the table at the top of this file. Notoriety set the shape and set it well — no bribe, no passive decay, but a way down through play — and happiness answers the same way, to feeding people properly, housing them, paying them and keeping the port steady. A test asserts a fortune cannot buy a mood a working port cannot earn.
A ship puts in between days 40 and 50 with animals aboard and you buy one. It is the only pet the run will offer: the moment a second is possible the game stops being about which animal suits this run and starts being about collecting the set.
| Raises | Lowers | |
|---|---|---|
| Dog | Milk | Ore |
| Cat | Grain | Milk |
| Bird | Ore | Grain |
| Monkey | Timber | Tools |
| Turtle | Tools | Timber |
Every product is raised by exactly one pet and lowered by exactly one, so nothing is strictly best and the pick is a read on which chains your run leans on. No pet touches meat, because meat is what pets eat — one that raised it would be partly feeding itself, and its upkeep would stop being a cost. Both properties are asserted by test, since they are what a carelessly added sixth animal would break without anyone noticing.
Only the timing is random. Whether is guaranteed and which is your pick, which is what keeps it clear of a rare drop to chase.
Entirely opt-in. Build any of the four dark sheds and free traders start calling; build none and you will never see contraband, never be inspected, and can still win.
When it opens. Distillery on day 25; Bonded Cellar with the warehouse;
Powder Mill and Privateer Berth with the smithy. Deliberately four separate
gates rather than a chain: each dark shed used to unlock only once the previous
one had been built, and measured against a complete human run — 93 days,
lighthouse lit — the Bonded Cellar and the Privateer Berth never became
available at all. The player reporting "the dark trade unlocks too late to
matter" was describing half a subsystem that was not late but absent. The
Cellar in particular sat behind the Distillery, so concealment could only be
bought after becoming exposed. No payoff number changed; two links were cut.
A test in progression_test.dart now fails if any dark shed is ever gated
behind another.
The quay is reactive: ships arrive and you answer them. Two mechanics let you take the initiative instead.
The chandler sells raw stock and food on demand at a 1.62× markup. A shortage becomes something you fix now rather than something you wait out. It is deliberately dearer than an Import Berth (1.45×, staffed, rate-limited) — the berth is the efficient bulk pipe, the chandler is the premium for now. It will not sell a finished good at any price; a test asserts every leg of the win condition is absent from its stock list.
Voyages load your own cargo and send it to a port that actually wants it:
| Days | Risk | Pays well for | |
|---|---|---|---|
| Ostmark | 3 | 3% | rope, timber, wool, planks, flax |
| Greyhaven | 5 | 7% | grain, fish, tools, barrels, meat, bread, cheese |
| The Reaches | 9 | 14% | spirits, powder, sailcloth, tools, ore |
Generated from kDestinations rather than typed: the table this replaced had
drifted from the game before the livestock goods arrived — it listed sailcloth
at Ostmark and powder at Greyhaven, which neither ever paid for. Milk, eggs and
spice have no market abroad on purpose (they would not survive a crossing, and
spice is for barter), and a test fails on any other good without one.
You get a quote up front, so the payoff is known before you commit. The point is that a voyage never touches your local price — it is how you move volume without collapsing your own market, paid for with cargo tied up and at risk. Ten powder buys an escort and halves that risk; a privateer scare doubles it.
Four hiring tracks, three tiers each, hired in order. Captains cut crossing time (−15/−28/−40%) and risk; merchants raise prices at home and abroad (up to +20% at the quay, +26% on voyages); privateer captains lift the odds at the rail (+8/+15/+22%) and the booty a won boarding lands (×1.15/1.32/1.55), and are offered only once a Privateer Berth stands — an honest port never sees the card; reeves bring every ripening shed on sooner (×1.25/1.5/1.8), and are offered once anything that ripens stands — a grange, a pasture, a byre or a hen house. They waited for a grange at first, and a port that put its herds in first met them after the herds had grown.
Carting is not hired. It used to be a fourth officer, the quartermaster. A player pointed out that convenience should never compete for coin or a berth against the two hires that change how the economy works, which was right: the port now carts itself as it grows, at the quartermaster's exact old gates (5 / 9 / 13 producing sheds → every other evening / every evening / hourly). It costs nothing, and the berth it used to occupy became the privateer captain's.
They cost coin to sign and a standing cost every day. That is the design: a one-off purchase is a coin dump, a payroll is a decision you have to keep affording. Pay someone off and it stops.
Commission, and the bug that forced it. The earners were once on a flat 5c a day. Measured on a mid-game consignment, the first merchant returned +150c a crossing against that 5c — a tenfold return that repaid its hire in four voyages and printed money after. The fault was structural rather than numerical: a flat cost against a percentage benefit is always eventually free, because the benefit grows with your cargo all run while the cost does not. Tuning the 5c only moves the day it stops mattering.
Captains and merchants are therefore paid the way factors and shipmasters actually were — a small retainer plus a commission on what passes through their hands (3–8% for a captain, 3.5–11% for a merchant). The cost now scales with the benefit. A captain is the interesting case: commission is a pure cost to them, so a retained captain makes every crossing quicker and safer and pays less per voyage — the gain has to come from sailing more of them. The privateer captain takes no commission: prizes are not sales.
Officer berths. A port supports one officer until 9 staffed sheds, two until 16, three after. The first hire is a choice between price, speed and having your boarders — not the first item on a shopping list you will finish anyway. Promoting someone you already retain never needs a new berth. In a probe run the second berth opened on day 50 and the third on day 88, against a win around 116.
Staffed, not built, and the distinction is the whole gate: an empty shed costs nothing to keep, so counting built ones let a player throw up cheap huts nobody worked in and buy all three berths outright for a few hundred coin. A working port supports officers; a field of empty huts does not.
"Working shed" means BuildingDef.isProducer — anything that draws hands and
does something with them, which includes the Import Berth. It has no
outputs map (it spends coin by the hour and lands raws by another path), so an
earlier check for outputs alone ignored a shed with three hands in it. It is
absent from the carting count on purpose, though: imports land in the stores
rather than a yard, so there is nothing there to cart.
Seeing them work. A consignment quote is itemised — cargo value, what the factor added, what they took back, the harbour charter, and what actually reaches you — and the crossing shows the lane's own length against the one your captain will sail. The quay rows name the factor's net price per unit. All of it is computed by the sim and handed to the panel rather than reassembled in the UI, because a breakdown that does its own arithmetic will eventually disagree with the figure beside it.
Showing the number is what caught the next bug: crossings were rounded to whole days, so a first captain on the 3-day lane turned 2.55 into 3 and delivered nothing while still charging commission — a trap on the shortest, most-used route, bought by the first hire of a run. Crossings are measured in hours now, and every captain tier saves real time on every lane, which a test asserts.
The Charters panel carries a Give up the venture button, directly under the card showing the run in force. It ends the run and destroys every charter and record earned before it, and there is no undo.
Two separate things, and only one of them is available mid-run:
| when offered | leaves the port | keeps charters and records | |
|---|---|---|---|
| Set sail anew | only once the Light is lit | yes | yes |
| Give up the venture | any time | yes | no |
A run in progress cannot be put down and picked over. "Set sail anew" used to be available at any moment and kept the whole collection, which made abandoning a run free: quit on day 4, quit again, until the charters and the weather look kind. That is a reroll, and every record after it means nothing because it was drawn rather than played. It now waits for the run to be finished — the only moment "keep what you have earned" was ever meant to describe.
The button still has to exist, because chooseCharter does not begin a run:
without it, winning would strand you with a charter you could never sail under.
That is the whole reason it was gated rather than removed.
Leaving early is still possible at any time. It costs the collection.
It exists because a save can reach somewhere no play recovers from: the case that prompted it was a browser tab left open across several updates, found on day 1000-odd with no coin. Freezing the world while you are away (below) should make that particular route impossible now, but a game with no exit from a dead save is worse than one with a costly one. The button also appears before your first win, when there is nothing yet to lose and no other way to start over.
The port does not work in your pocket. Close the app, background it, put the
phone down for three days — the world is exactly as you left it, and the clock
starts again when you come back. Balance.progressWhileAway is false and the
timer stops on the way out.
It used to run the sim forward for the time you were gone. That was meant generously (no timer to skip, nothing to buy to speed it up) but it made closing the app consequential in a game whose whole premise is that it should not be: the town eats and wages fall due whether or not anyone is watching, so a run left overnight could be found starved in the morning. One such aftermath reached the developer as a run report of 89 days of population zero.
GameState.catchUp is kept and still tested, because the behaviour is worth
being able to reason about, but nothing in the app calls it.
The game autosaves every ten seconds while the clock runs, on every deliberate action, and the moment the app is backgrounded or the tab hidden.
This is worth writing down because for most of the project's life it did none of
those things. GameController.save() existed, was correct, and had no caller
anywhere in lib/ — no lifecycle observer, no timer, no button. Every run
lived in memory and died with the process, and the offline catch-up in load()
could never fire because nothing had ever written a timestamp for it to read.
The test suite did not catch it for a simple reason: the test called save()
itself, so it proved that saving works rather than that the game does it.
There is now a second test that refuses to call save() and asserts a played
run comes back anyway; it fails against the old code, which is the only reason
to trust it.
A player's verdict, after several full runs: "I skip it because it doesn't feel worth the investment. It doesn't speed up progress or anything, just adds another layer for no real payoff."
Measured rather than argued. Per worker-hour at base prices the honest Smithy pays 2.85 and the Distillery 1.50, so contraband sold for coin is 0.53x the best honest chain. Counting the barter premium free traders pay for it (parity 1.25-1.70 on bulk raws) it reaches 1.10x at typical parity and 1.36x at best — and the layer still charges you hands in a cellar, permanent notoriety, seizure of whatever is exposed, and raids keyed to the hoard.
Then the bot learned to play it (--dark), building the contraband chain in
place of the second smithy and weaver:
| wins | win day (min / median / max) | |
|---|---|---|
| honest | 8/8 | 89 / 120 / 143 |
| dark | 8/8 | 82 / 108 / 120 |
So it is worth about twelve days of a hundred and twenty — real, and completely invisible while every number on screen was one it cost you. The port now keeps the other half of the account: what the dark trade has earned in sales and barter margin, against what the Revenue and the rivals have taken.
This is the same shape as the merchant, and the third time a thing that "felt like nothing" turned out to be a thing nobody was shown.
The port opens deliberately small: two sheds and five hands, which is one
idea — post people to work, cart in what they make. Everything else arrives
through an unlock tree, one building at a time, each earned by something you
did (lib/sim/progression.dart).
Locked buildings are still listed under Still to come, greyed, with the one condition that opens them — so the shape of the game ahead is visible without being dumped on you at once. Unlocks are checked the moment you collect, build or turn a day, so the announcement lands next to the action that earned it, and they are sticky: selling the timber that unlocked the sawmill does not take the sawmill away.
Sheds hold a week of output in their yards, and every warehouse makes each yard half again as big — so "my sheds keep filling up" has an answer you can go and build rather than a stopwatch to obey.
Raise the Saltwind Light: 8,000 coin + 130 planks + 80 tools + 120 rope + 70 sailcloth + 30 cheese. It deliberately demands one good from every chain rather than a pile of coin — and the cheese is there because a lighthouse is manned: you do not finish one by raising the tower, you finish it by victualling it so a keeper can live out there through a winter.
A run keeps the bill it began under. Changing the requirement under a port that has already earned it would take someone's run away, so the bill is recorded when the run starts and honoured on every load after.
lib/
sim/ pure Dart, zero Flutter imports — the whole simulation
resources.dart resource table, prices, heat weights, ResourceBag
buildings.dart building catalogue + recipes (data-driven)
market.dart price indices, ships, free traders, barter, seeded PRNG
terrain.dart the island, generated from tile coordinates
trade.dart destinations and voyages
retinue.dart captains and merchants
progression.dart the unlock tree
events.dart event catalogue + scheduler (seasons, omens, mercy floors)
game_state.dart tick loop, population, imports, notoriety, the Revenue
game_controller.dart clock, speed, persistence, offline catch-up, hints
ui/
world_view.dart the port as a real 3D scene — see below
game_screen.dart the HUD, the dock, and the panels over the base
shed_list.dart every shed in one scroll, and the shed controls
market_tab.dart the quay: cutter, crown ships, free traders, prizes
trade_panel.dart retinue, voyages, the chandler
build_tab.dart lighthouse, Letters of Marque, the catalogue
hints.dart contextual onboarding nudges
test/
sim_test.dart economy, imports, persistence, balance invariants
events_test.dart scheduling, eligibility, effects, determinism
privateer_test.dart notoriety, the Revenue, prizes, the honest path
harbour_map_test.dart plot layout + golden renders of the map
app_test.dart widget tests incl. four phone viewports
tool/
balance_probe.dart 8-seed headless bot that plays full games
lib/sim/ importing no Flutter is deliberate: the simulation is testable
without a widget tree, and the probe runs it headless at thousands of ticks per
second.
There are no tabs. The harbour fills the window and everything else floats over
it: a HUD along the top (coin, hands, day, speed, pinned resources), a dock
along the bottom (Build / Quay / Trade / Sheds / Log) with live badges, and
panels that slide up and dismiss. Tapping a building brings its controls up in a
bubble over the base rather than sending you to another screen. A test asserts
find.byType(TabBar) finds nothing, so the spreadsheet cannot creep back.
Sheds fill their own yards. Production accumulates in the building until you collect it — a brass bubble bobs over anything ready, and Collect all empties the port in one press. A full yard stalls the shed, visible as the bubble turning amber. Nothing is ever lost, so putting the phone down slows you but never punishes you, and there is nothing to sell you to make it go away. Yards hold four game-days, so offline progress is unaffected.
world_view.dart is a real 3D scene, not an isometric one: a camera with a
position, a look-at target and a perspective frustum, with every polygon
projected through it. That buys three things a 2:1 diamond grid cannot have —
true perspective (near things are bigger, the ground converges), a camera you
can orbit and tilt, and per-face lighting from a fixed sun.
The island is geometry: water sits below sand, sand below grass, rock above that, and every height change draws a cliff skirt down to its neighbour. Buildings are boxes with pitched pyramid roofs. Picking is done by ray-casting — your tap is unprojected and intersected with the ground plane — so tapping and dragging stay accurate at any camera angle. Hold a building to pick it up and drag it; target tiles light green if it fits and red if it does not.
It is one CustomPainter. The geometry is small enough that projecting and
depth-sorting it by hand each frame is cheaper than pulling in a 3D engine, and
it runs identically on web and mobile.
The scene is covered by a golden test. flutter test --update-goldens rewrites
test/goldens/*.png, which is the fastest way to actually look at a change —
it caught the camera framing empty water instead of the island, and back-face
culling that was dropping every roof.
tool/balance_probe.dart plays eight fixed seeds with a competent-but-not-
optimal policy. It is a tuning instrument, not a test.
Current: 8/8 seeds win, median day 135, spread 53 days, peak coin 1.1× the requirement.
The clock has been set by playing, twice:
| base rate | one day at 1× | full run at 1× | verdict |
|---|---|---|---|
| 1.0 | 24s | ~55 min | "too quick to get a feel for what's going on" |
| 0.35 | 69s | ~2.6 h | "just a bit slow" |
| 0.70 | 34s | ~1.3 h | current — the pace that actually felt right |
The last row was not a guess. Playing at 0.35 felt sluggish at 1× but right at 2×, so 2× of 0.35 — 0.70 — became what 1× gives you. A comfortable default should not be something the player has to reach for.
Speeds are 0, 0.5, 1, 2, 4. The half step exists because 1× is now tuned to a
comfortable pace rather than a slow one, so watching the port closely needs
somewhere to go downward. 4× is a deliberate speedrun: one full playthrough
hunting bugs took 45 minutes at the old 4×.
That is the third of three readings, and the history is the point:
| wins | median | spread | peak coin | |
|---|---|---|---|---|
| Before the coin sink | 6/8 | 200 | 296 days | 10.6× |
| After the Import Berth | 8/8 | 111 | 42 days | 1.0× |
| With events and the dark trade | 8/8 | 104 | 14 days | 1.1× |
Four findings from the probe, each of which changed the design:
The bot is calibrated against a real run, and the gap is measured.
tool/calibrate.dart lays the bot's own trace against a player's — both are
PA1 run codes, so one tool reads either:
dart run tool/balance_probe.dart --charters=a_full_purse,poor_soil --journal=/tmp/bot.pa1
dart run tool/calibrate.dart tool/reference_runs/human-*.pa1 /tmp/bot.pa1
| before | after | |
|---|---|---|
| combined curve error | 54.0% | 28.5% |
| median win day | 143 | 126 (player: 93) |
| first consignment | day 139 | day 4 (player: day 13) |
| consignments sent | 4 | 23 (player: 11) |
What was wrong: the policy held a flat reserve of planks 190, tools 95, rope
135 and sailcloth 105 — which is the lighthouse's own bill of materials plus a
margin. It was banking the win condition from day one, so stock - reserve
never went positive and it could not sell a plank or fill a consignment for a
hundred days. Its income was strangled by its own savings plan. It now holds a
working reserve while the port grows, and banks the full bill only once it
turns to finishing.
Two things that did NOT work, both measured rather than assumed:
--hire until the income gap closes.Left over from the same measurement: rank-three officers cost 5,200 and 6,600 coin, together more than the 9,000 the lighthouse asks for, and the player bought neither.
The probe underestimates a real player's income by roughly 40% in the early game. Its selling heuristic holds a fixed reserve and only trades with whoever happens to be at the quay. Measured: the cheapest retinue hire looked affordable on day 23 by the model, and was bought on day 13 in play. Assume anything priced against the probe will be reachable sooner than it says.
It has closed some of that gap. The bot now sends consignments, which it did not do for most of the project — an omission that made the first merchant A/B as worthless and measured a charter that does nothing but lengthen crossings at exactly zero difficulty. With sailing added its range is 89-143 days against a player's reported 94, where before its best seed was 105 and real play beat it by eleven days.
Known caveat: the probe fully reallocates its labour every single day, so it absorbs disruption a human never would. Events moved its median by only ~7 days; expect them to bite considerably harder in real play. Do not tune event severity against the bot.
All tuning lives in Balance in lib/sim/game_state.dart and EventTuning in
lib/sim/events.dart. The building and event catalogues are plain const
lists — adding a chain or an event is one entry.
The Log panel has two buttons. Copy run report puts the long readable trace on the clipboard. Send offers to file that run somewhere it can be read.
Send never fires by itself. A sheet opens first showing the exact payload, in full, with Send / Copy instead / Not now at equal weight — no pre-ticked box, no second ask on the next launch. Silent upload of play data would be telemetry, and shipping telemetry in a game sold on not having dark patterns would be the bait-and-switch this project exists to avoid.
What travels is PA1, not the readable report. The readable one cannot go
by link at all: a 200-day run is 7,463 characters, 11,549 once URL-encoded, and
GitHub's endpoint accepts about 6,000, kills the connection near 8,000 and
returns 414 at 12,000 (measured against the real endpoint, not folklore). PA1 is
the same run in 2-4 KB of URL-safe characters — see lib/sim/run_code.dart.
Decode what comes back with:
dart run tool/decode_run_report.dart reports.csv # or a bare code, or stdin
Choosing a destination — one constant, ReportEndpoint.destination in
lib/report_endpoint.dart:
| Google Form (current) | GitHub issue | ||
|---|---|---|---|
| tester needs an account | no | no | yes — a sign-in wall |
| identity attached | their email address | none | their username, publicly |
| setup | none, works today | ~5 minutes | none |
| collects into | an inbox | a spreadsheet | the issue tracker |
| payload budget | 2,600 | 5,500 | 2,800 |
| 200-day run | every 2nd day | every day | every 2nd day |
| failure mode | silent clipping (detected — see below) | none known | 414, or a dead connection |
Runs at or under ~120 days keep full daily resolution on any of the three.
GitHub's budget is halved because a signed-out tester's whole URL is echoed
back inside ?return_to=, so the payload has to survive being carried twice.
Email is the one that can fail quietly. Mail clients disagree about how
long a mailto: may be and clip rather than refuse. So RunCode.decode checks
the row count against the day count the sender declared in the header, and sets
lostInTransit when they disagree — tool/decode_run_report.dart prints that
loudly and above the table. A clipped report otherwise looks exactly like a
shorter run, and reading one as the other means drawing a balance conclusion
from a mail client's URL limit.
The form is the best of the three and the only one with no failure mode: it is
anonymous, needs no mail client, and its budget is wide enough to keep a
300-day run at every-other-day. It is what ships now. Responses land in the
Sheet linked to the form; publish that Sheet to the web as CSV and give the URL
to tool/fetch_reports.py as PORTS_AHOY_SHEET_CSV, and collection needs no
credential on the machine at all.
Neither setting is a secret. A form id appears in every prefilled link the form hands out, and the repo is already public. No token is compiled into the app, and a test asserts none ever is: the APK and the web bundle are both public artefacts, anything inside them is readable by anyone, and GitHub's secret scanning would revoke a leaked token anyway. That is precisely why the app opens a page the tester submits themselves rather than posting anywhere on their behalf.
The world draws on its own clock, separate from the game's: 60 frames a second by default, with Low 30 / Medium 60 / High 90 in the Log panel, and a default that drops itself to 30 on a device that cannot keep up. Pausing stops the work — chimneys, windmills, people — and leaves the sea moving. Smoke leans on one wind for the whole port; consignments are seen to sail and come home; weather builds through its omen, so a gale, a frost or ice in the shallows can be seen coming.
Every sound is synthesized from nothing by tool/make_sounds.py, so the game
owns all of it. Sea, wind and rain loop; a bell, coins, a mallet, a gun,
thunder, gulls and a chime mark what happens. Sound is on by default, mute is
one tap in the HUD and remembered, nothing plays before the first touch, and
any file in assets/sounds/ can be replaced with a recording of the same name.
--hire) and work
the dark trade (--dark), and it sends consignments — every one of those was
added after its absence produced a wrong reading. Hiring is off by default
because it measured worse, at 90 days against 82: wages crowd out houses.What is intended next — settling the dark trade, the charter weights, the Play Store track — is in ROADMAP.md. It carries no dates.
This section claimed for a long while that the game had never been played by a human, had never run on a physical device, and that no Android build had ever been produced. All three are now wrong and were left stale far too long — a README that overstates what is untested is as misleading as one that overstates what works.
The game has been played through to the lighthouse repeatedly on a Pixel 9 Pro XL, both as the installed APK and as the web build, in sessions of 45 to 55 minutes. Signed release APKs are produced routinely from this repo. And there is now content past the lighthouse — a finished run earns a charter to carry into the next one.
Nearly every bug of consequence in the last stretch came out of those sessions rather than out of the test suite: population stalling for eleven days, the merchant being a formality, consignments not leaving the stores, the charter screen describing the wrong run, an event whose description said nothing, the import berth not counting as a working shed. The suite is 300-odd tests and green throughout; play found what it could not.