Skip to content

Map topology and surrender - #1

Merged
AcideFluorhydrique merged 11 commits into
mainfrom
map-topology-and-surrender
Sep 22, 2026
Merged

AcideFluorhydrique merged 11 commits into
mainfrom
map-topology-and-surrender

Conversation

@AcideFluorhydrique

Copy link
Copy Markdown
Owner

No description provided.

The world map is stretched on purpose — Europe and Japan wide, the oceans
narrow — and the stretch itself cannot break topology: longitude and
latitude each map to the grid monotonically. What broke it was elsewhere.

Finland had no polygon. Scandinavia's east edge ran up the Gulf of Bothnia
and Europe's west edge ran north from St Petersburg, and everything between
21°E and 29°E came out as sea. Helsinki was one anchored hex, St Petersburg
and Murmansk took the rest, and Finland touched Latvia and Belarus across a
gulf that was not there. Finland is now drawn, the Gulf of Bothnia is sea,
the Gulf of Finland is carved as a strait, and 60–72°N gets proper rows
instead of 2.6° each. Kiruna, Sundsvall, Gothenburg and Oulu give Sweden and
Finland their own ground, so Oslo no longer owns central Sweden and
Copenhagen no longer owns the Swedish coast.

Straits were carved everywhere except on city hexes, so two cities facing
each other across one became a land bridge: Busan and Fukuoka were
neighbours and Japanese infantry could walk into Korea. The same happened
across Florida, the Irish Sea, Hormuz, Bab-el-Mandeb and the Red Sea. Island
groups (geodata.ISLANDS) now never touch other land, and pairs of mainland
polygons that a sea divides (geodata.SEAS: Arabia against Africa, Iran and
South Asia) never touch each other. When two cities would, the island city
moves to the nearest hex of its own island. Cities also snap only to land
on their own side of the water.

Several regions were simply missing: Jutland, the Indus valley and the
Himalayan border between India and China. The Middle East polygon started
off the Libyan coast, turning the Levantine Sea into land that joined
Alexandria to Syria. Those are drawn now. Hamhung gives North Korea its
north-east, which Shenyang used to own, and Guwahati gives India its
north-east, which Dhaka used to own. An island with no city goes to one
owner whole instead of being split tile by tile.

tools/topology.py writes the geography down as two lists, borders that must
exist and seas that must stay seas, and genworld now fails when the world
map breaks either one. CI regenerates the maps, so a coastline edit that
reopens a land bridge fails the build.

Every map changed, so tile and province indices in old saves point at the
wrong places. A save now records the map's fingerprint, and one that does
not match is refused with its own message instead of loading units into
the sea.
…emy cities

This follows the reference game's observable rules, written down in the new
docs/original-behavior.md together with what is still unknown about them.

A nation that loses its last city surrenders on the spot. Every unit it has
left disappears, including ships at sea and troops deep in someone else's
land, and whatever provinces it still holds go to the nation that took that
last city. Before, a nation was only gone once it had no provinces and no
units at all, so a beaten country lingered for as long as one destroyer was
still afloat somewhere. A nation that never had a city is unaffected and
still disappears only when it has nothing left.

Conquest is won by taking every city of every enemy: the members of the
opposing bloc, plus anyone you are at war with, such as a neutral you
declared war on. Allies and neutrals you left alone do not count. Since an
enemy surrenders with its last city, "every enemy has surrendered" and
"every enemy city is taken" are the same test. The old fallback of holding
80% of the world is gone; it could only be met by fighting allies and
neutrals. With no enemy at all there is no conquest victory; you have to
declare war first.

The objectives panel, the event ticker, the README and the store listing say
the same thing. Tests cover a surrender with a leftover province and a ship,
a lost city that was not the last, a win that leaves a neutral alone, a
declared-on neutral that must be beaten too, and a map with no enemy.
Running out of turns in conquest is a loss in the reference game. Session
already treats "turn past the limit and not yet won" as defeat for any
scenario; conquest scenarios were just generated with no limit.
write_conquest now takes turn_limit, and a test pins the defeat. The limit
for each year is still to be decided, so the shipped scenarios keep 0.

docs/original-behavior.md now records that an AI ally taking an enemy's
last city has the same effect as the player taking it, and that a bloc
member joining the war late only matters in play that cannot happen
without cheating, so the conservative rule stays.
The guard asserted 20–45% land, and the map was already at 44%: the oceans
are squeezed on purpose, so land takes far more of the grid than the real
world's 30% the comment claimed. Drawing Finland, Jutland and the Indus
valley and giving 60–72°N proper rows brings it to 46%. The test exists to
catch a generator that turned everything to sea or land, and 20–55% still
does that.
Denmark touched Sweden: Copenhagen's hex bridged the Øresund. Jutland and
Zealand now never touch Scandinavia by land (geodata.SEAS), and Zealand
has its own polygon so Copenhagen sits on it.

Norway was in two pieces. The Scandinavian polygon's north coast only
reached the grid's top land row at the North Cape, so an inland Swedish
province ran to the Arctic between Narvik and the far north. The coast is
drawn up to 71.3°N, Trondheim and Hammerfest give Norway its middle and
north, and Luleå replaces Kiruna as Sweden's northern city so it stops
winning ties on the Norwegian coast. topology.py now also checks that a
list of countries' mainland is one piece; it found Chile cut in two by
Bariloche's province, so Argentina's Patagonian cities are now Neuquén
and Comodoro Rivadavia on the east side and Chile gets Temuco and
Coyhaique on the west.

Vladivostok was an island: the Siberian polygon cut straight from 135°E
48°N to 130°E 44°N and left Primorye out. The Philippines were one blob
from 120°E to 126.5°E that counted the Sibuyan Sea as land; they are now
Luzon, the Visayas and Mindanao.

Hong Kong, Gibraltar (new) and Belfast (new, Northern Ireland) are
places.SINGLE_TILE provinces: one hex, no territory grown around them.
The Strait of Gibraltar line moved to the southern half of the strait so
Gibraltar stands on the Iberian side, and Europe never touches Africa by
land. GBR–IRL is now a required land border and GBR–ESP one too.

Cuba, the Dominican Republic and Panama are independent neutrals in 1939
and 1943, not part of the United States. Ethiopia is neutral in 1943
instead of an Allied member. In 1939 and 1943 the Republic of China is
shown as "China" under 🇹🇼; from 1950 China is the People's Republic 🇨🇳
and Taiwan the Republic of China 🇹🇼. The Republic of China starts 1950
with its remnants: two infantry in the Shan States, one south-east of
Kunming, and ships off Zhoushan, off Fujian and in the strait.
A new game made the player watch every nation ahead of them in the
scenario file move first — in conquest that is dozens of AI turns before
the first click, because the file lists nations by strength. The reference
game starts on the player's turn and the AI moves after it.

Session now puts the player at the front of the nation list and keeps the
scenario's order for everyone else, so every round starts with the player.
Nation ids shift with that, so the save format goes to version 2 and older
saves are refused as outdated. docs/original-behavior.md records the turn
order.
Every fresh runner generated its own debug keystore, so each CI debug APK
had a different signature and could not be installed over the previous
one. The workflow now restores a fixed keystore from the DEBUG_KEYSTORE
secret (base64) to the path the Android build tools read by default, so
the Gradle files stay unchanged and no key is committed. Without the
secret, as on pull requests from forks, it warns and falls back to a
random key. Release builds are untouched; F-Droid signs those itself.
…lity

The shared debug key from the previous commit is dropped: phones get the
release build. CI still builds the release APK without any signing config,
so the unsigned artifact is what F-Droid will build from source, and then
signs it with apksigner using the RELEASE_KEYSTORE secret (base64) and its
password and alias secrets. The keystore exists only in the runner's temp
directory for that one step. The certificate fingerprint is printed for
F-Droid's AllowedAPKSigningKeys. Without the secrets, as on pull requests
from forks, the job warns and ships only the unsigned APK.

The debug APK is no longer built or uploaded; nobody installs it and its
random per-runner key made it impossible to update anyway.

A new reproducible job builds the release twice, from the checkout and
from a second worktree at another path with the build cache off, and fails
unless the two APKs are byte-identical. F-Droid only publishes the
developer-signed APK when its own build matches, so this catches
timestamps, absolute paths and unstable ordering before a release does.
Both still described the first version: five training exercises, 20 unit
types across land, sea and air, fighters that rule the sky, a 96×60 map
and everyone at peace in conquest. They now describe the game as it is:
three campaign chapters with nine battles playable from either side,
conquest in four years with blocs and surrender, player-first turns,
17 unit types plus air missions, single-hex strongholds, and a stretched
world map whose borders and straits are checked on every generation.

The README gains a section pointing to docs/original-behavior.md, and one
on releases: CI signs with the project key, the release must be
reproducible, the certificate fingerprint, and how a v<versionName> tag
publishes a GitHub Release. The test and generator lists cover
SessionTest and tools/topology.py. The versionCode 1 changelog, not yet
released, now matches what that release will contain.
Pushing v<versionName> runs the whole workflow and, once the build, the
reproducibility check and the generated-assets check have passed, a
release job creates the GitHub Release with the signed APK attached as
mapconquer-<versionName>.apk, the stable name F-Droid's Binaries URL can
point at. The notes are the fastlane changelogs for that versionCode in
all three languages, with the APK's SHA-256.

The version stays in the source, where F-Droid reads it; the job fails if
the tag does not match versionName or the changelog is missing. On a tag,
missing signing secrets are an error rather than a warning, so an
unsigned APK can never be published.
Flags were emoji only, so the Soviet Union flew the Russian tricolour,
East Germany had no flag at all, and Germany in 1939 and 1943 flew the
Federal Republic's. No emoji font has the flags these need.

A scenario's flag can now be an @-code as well as an emoji, and
render/FlagArt.kt draws those codes in the style of the default Android
emoji flags: the same box, measured from a real emoji flag so the two
always line up, a wave along the top and bottom edges, a light sheen and
a faint dark edge. Each is composed once per pixel size and cached, since
the map shows a hundred city badges a frame. The five places that drew a
flag as text now go through FlagArt; the two that glued flag and name
into one string draw them separately.

- @su: red field, gold star, hammer and sickle — the Soviet Union in
  every scenario from 1939 to 1985.
- @GDR: black, red and gold with the wreath, hammer and compass, from 1959.
  In 1950 East Germany's flag was the same plain tricolour as West
  Germany's, and that is what it flies.
- @reich: Germany from 1935 to 1945. The historical flag is the swastika
  flag; by the maintainer's decision the game shows the Wehrmacht's Iron
  Cross instead, black with a white rim on field grey.

tools/places.py picks the flag by nation and year (flag_for), for both
conquest and campaign scenarios, and ScenarioAssetTest rejects any @-code
FlagArt does not know.
@AcideFluorhydrique
AcideFluorhydrique merged commit 89a0293 into main Sep 22, 2026
4 checks passed
@AcideFluorhydrique
AcideFluorhydrique deleted the map-topology-and-surrender branch September 22, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant