Two of three runs on the Zulu build died with a HotSpot SIGSEGV (once in the
test JVM, once in the Gradle daemon during lint). Corretto is reachable from
the runner (corretto.aws) and builds the project the same.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
The sheet arrives over the socket just after connected flips, so on a slower
runner the comparison raced it (ScorekeeperSessionTest, line 167). Same
pattern the sibling test already uses.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
setup-java's temurin distribution resolves versions through api.adoptium.net,
which fails TLS from the runner (EPROTO) while api.azul.com is reachable.
Any JDK 17 builds the project.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
- plgVersionRunOffset=1000: stamped build number is run number + offset, so
Gitea's restarted run counter cannot produce a versionCode below an
installed one (GitHub reached prod 30, dev ~172). Tags and versionName use
the same number.
- Drop the GitHub release steps; plg.wallacenet.ca is the only channel.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
Keeps Gitea's history reachable (also preserved as gitea-pre-github-main)
while the tree stays the GitHub one: package org.prolibertate.games, OTA
from plg.wallacenet.ca. Gitea's package rename and Gitea-releases updater
are intentionally not carried over.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
Pages serves index.html with 200 for unknown paths when there is no
404.html, so a never-published channel looked published and jq failed on
the HTML. Add a real 404 page and treat a 200 without a latest.json as
absent.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
A push-triggered first deploy cannot set the first_deploy input, and the
domain answers 403/1014 until the Pages project exists. 1014 means nothing
exists to preserve, so handle it like a 404. Other 403s still abort.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
Both workflows gain a deploy-pages job that stages their channel into one
Pages site, carrying the other channel over from the live site. The in-app
updater now reads /prod|/dev/latest.json from plg.wallacenet.ca instead of
the GitHub API. GitHub releases are still published for one transition
deployment so existing installs can fetch the new updater.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
Follows the Gitea repo rename: the applicationId (and its .dev variant)
now matches the repo, the launcher name and the docs, instead of carrying
the abandoned Pilgrims of Katan identity.
The JVM namespace stays org.prolibertate.games, so no source file moves;
only the installed identity changes.
Co-Authored-By: Paperclip <[email protected]>
The repo was renamed from PilgrimsOfKatan to ProLibertateGames on the
Gitea instance. Update every reference so nothing points at the old name:
- OTA updater: the repo constant and the User-Agent
- publish-release.sh: the default repo, the asset name, the release title
- CI/release workflows: the artifact names and the staged APK name
- settings.gradle.kts: the Gradle project name
- Protocol.kt / LanTransport.kt: the mDNS service label and the Wi-Fi lock tag
- docs (README, CLAUDE.md, docs/CI.md) and the update-policy test fixtures
The installed applicationId is left unchanged in this commit.
Co-Authored-By: Paperclip <[email protected]>
The bootstrap rebranded the app to Pilgrims of Katan (WALA-118 review);
the decision is to keep the upstream's own identity instead.
- Title and splash: the upstream originals restored exactly — the Wallace
tartan splash under the "Pro Libertate Games" uncial wordmark
(SplashScreen.kt, Tartan.kt), the tartan launcher icon, and the
Theme.ProLibertateGames window theme. The game screens go back to the
upstream WallaceGold/WallaceRed accents the tartan is woven from.
- Names and colours: launcher names back to "Pro Libertate Games" /
"Pro Libertate Games DEV"; the channel colours back to the clan red /
hunting green setts (resValue tartan_field and friends). The installed
identity stays ca.wallacenet.pilgrimsofkatan (+ .dev) — that is the
package, not the brand.
- Catalogue: Katan is listed under Board Games (3-4 players, original
blurb, no third-party marks) with available = false, so dev builds show
it as "Coming soon" and a production build hides it until its engine
and screen land.
- Docs: README/CLAUDE.md/docs/CI.md updated to the restored identity;
CatalogTest's invariant now names Katan as the one deliberate exception
rather than asserting nothing is unfinished.
Structural bootstrap untouched: applicationId, OTA repointed at the Gitea
releases, CI workflows, version stamping.
Verified: ./gradlew assembleDebug and testDebugUnitTest green — 628 tests,
0 failures.
Co-Authored-By: Paperclip <[email protected]>
A live publish on git.wallacenet.ca kept coming back invisible to the updater:
the create accepted "draft": false, and the release was still stored as a
draft once the APK was attached — so the anonymous list the app reads came
back empty even though every publish step reported success.
The script now attaches first, then reads the release back and PATCHes the
state if it did not stick, failing loudly if the correction is refused. A
draft is not a channel a preview build can silently become.
Also lists all 22 catalogue games in the README's table; seven were missing,
which read as "the app has 15 games".
Verified against the live instance: anonymous list shows
v1.4.1001-dev as a published prerelease with a tag-named asset, the
production /releases/latest correctly returns 404 while only a prerelease
exists, and an anonymous download through the URL the app builds matches the
built APK byte for byte.
Co-Authored-By: Paperclip <[email protected]>
Two things the first live release check on git.wallacenet.ca showed:
- The publish script uploaded the APK under whatever the build called it
(app-debug.apk), which is the name the updater only finds by its fallback
rule. The script now names the asset after its tag, so the primary rule —
"the asset whose name contains the tag" — is the one that fires.
- The instance answers for two host names and reports asset URLs on the one
it treats as canonical, so the app would have fetched its release list from
git.wallacenet.ca and its APK from elsewhere. onReportedHost() now keeps the
path the server chose and puts the download on the host the app already
trusts, tested in UpdatePolicyTest.
Co-Authored-By: Paperclip <[email protected]>
Import of ProLibertateGames (upstream main, GPL-3.0) rebranded for
Wallacenet and repointed at git.wallacenet.ca.
- Identity: applicationId ca.wallacenet.pilgrimsofkatan (+ .dev for the dev
channel), launcher names "Pilgrims of Katan" / "Pilgrims of Katan DEV",
neutral hexagon branding replacing the Wallace tartan. Uncial Antiqua keeps
its OFL licence, and the GPL-3.0 LICENSE plus upstream attribution stay.
- OTA: Updater.kt now reads this repo's own Gitea releases
(/api/v1/repos/devadmin/PilgrimsOfKatan/releases...) and BuildConfig.
OTA_ENABLED is on; Gitea answers the same JSON shape the code already parsed.
- Build numbers: versionCode = CI run number + plgVersionRunOffset (1000),
with the tag and versionName derived from the same figure. The offset is
permanent, because Gitea Actions restarts run numbers at 1 and a reset would
otherwise make Android reject the next build as a downgrade.
- CI: ci.yml publishes a dev prerelease from dev, release.yml a production
release from main, both through scripts/publish-release.sh (Gitea REST API,
runnable by hand until an Actions runner is registered).
- Docs: docs/CI.md records the channel, numbering and runner decisions;
docs/README.md seeds the per-game RULES-* conventions; CLAUDE.md carries the
agent ground rules.
Verified: ./gradlew assembleDebug and testDebugUnitTest green on JDK 17 /
compileSdk 34, both variants stamped, installing side by side under the new
identity.
Co-Authored-By: Paperclip <[email protected]>
Tally sheets let any player score at any moment (+1, -1 or a custom amount)
with a log of entries that can be taken back. Entries are stored as one-player
rows so totals, undo and sharing work unchanged.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A guest was timing out with nothing ever shown on the host. Without a
device to look at, this makes the next test say where it stops, and fixes
what was found on the way.
- The host's Sharing panel shows links received and join requests read, which
tells "nobody reached this phone" from "reached it and not understood".
- The guest says when its link is up and it is waiting for an answer.
- A request waiting shows on the host's top bar ("Sharing . 1 waiting"), and
the Sharing panel steps aside while one is up so two dialogs never stack.
- Join is a top-bar action on a sheet already under way; it was only offered
on an empty one.
- LanTransport: a host being cleaned up no longer unregisters the
advertisement or clears the address of a newer host started in its place.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
A host can share its score sheet and others nearby can join it, found the
same way games are, or by typed address over a hotspot.
- A joining guest is not let in on its own say-so. The host is prompted with
the guest's name and answers Allow or Deny. A device already let in that
drops out (locked phone) is let straight back; the host can remove anyone.
- The host's sheet is authoritative. What travels is what was done (SheetOp:
add round, rename, move column, ...), not whole sheets, so changes made at
the same moment all land. A guest's own changes show at once and are laid
over the host's sheet until confirmed (SheetReplica).
- Sync asks the host for the sheet as it stands, and runs on its own when
the app returns to the front, as it does for a game table.
- Hello carries a purpose and hosts advertise one, so a game join screen does
not list score sheets and a game guest typing a sheet's address is told so.
- The Scorekeeper screen now expresses every edit as a SheetOp, so one grid
serves a local sheet, a hosted one and a joined one.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
Zeros are now stored so a typed 0 shows as 0 while a blank cell stays blank.
Tapping a score in an earlier round opens that round for correction with the
cursor in the tapped cell, instead of needing the row number first.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A phone locked when its turn came round could not play once it was
unlocked. The game was never wrong — the host still had the position and
the seat was still the player's — but the only route to it was gone, and
nothing rebuilt it. The board came back with the state it had before the
turn arrived, and every tap on it went nowhere.
A locked phone puts its Wi-Fi radio into power save, and a link carrying
nothing between one turn and the next is exactly what that drops. Four
things were missing, and all four are needed:
The radio is held awake for as long as there is a game on, which stops
most of these drops happening at all. FULL_HIGH_PERF rather than the
LOW_LATENCY its deprecation notice points at: that one only applies with
the screen on and the app in front, which is the case that worked.
A link with nothing on it is pinged, and answered. TCP does not report a
link that died this way — the read never returns and the next write goes
into a socket that leads nowhere — so without traffic of its own the app
could not tell a player who was thinking from one who was gone. Both ends
have to speak it or a quiet link would be dropped as dead, hence the
protocol bump; the lobby already turns a mismatch away with "update both
devices".
A write that fails now reports the link as broken instead of closing it as
though this end had meant to. That silence was the reason a dead board
answered nothing and said nothing about why.
And a guest that loses its link dials the host again — which is still
listening, on the same port, for the whole match — and is put back in the
seat it never actually left, by the device id it is already known by. The
table then resyncs, which is what fills the screen back in. Coming back
to the front asks for the same thing, since a client draws nothing but
what it was sent and anything pushed while the link was down was pushed
at nothing.
Sends are serialised while I am here: a message is a line, a write is
three calls, and the heartbeat writes on its own schedule.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GarwvAPvWHELNspro4foFY
Binding the join socket to the Wi-Fi network covers a phone that has
joined a hotspot. It does nothing for the phone that is sharing one:
that phone has no Wi-Fi *client* network, so there was nothing to bind
to and the code gave up — leaving the socket on the default network,
which is mobile data, and a connection to a guest going out over
cellular to die.
ConnectivityManager does not offer the tethering interface as a Network,
so there is no network to name. Binding this end of the socket to one of
our own addresses on the guest's subnet picks the route instead, which
is the only lever available. The subnet test assumes a /24, which every
Android hotspot hands out, and getting it wrong costs a bind that would
not have helped anyway.
This is the direction that was left broken: the owner of the hotspot
trying to join a game hosted by one of its guests.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
Two phones on one phone's hotspot could not find or reach each other.
Nothing was wrong with the Wi-Fi. There were three faults, and the first
one is the whole bug.
A hotspot has no internet behind it, and Android takes that literally:
the Wi-Fi network fails its connectivity check, stays unvalidated, and
mobile data remains the *default* network. Every socket opened without
naming a network is therefore routed out over cellular — where
192.168.43.1 means nothing. The join socket is now bound to the Wi-Fi
network before it connects, and the search for that network deliberately
does not ask whether it has internet, because asking is what breaks it.
Second, the manifest has asked for CHANGE_WIFI_MULTICAST_STATE since the
beginning, with a comment saying discovery needs it, and nothing ever
took the lock. Without it the radio drops multicast frames not addressed
to this device before the system sees them, and mDNS is multicast. Both
advertising and discovering now hold it.
Third, and not fixable: a phone sharing its own hotspot does not
reliably advertise over the tethering interface, whatever the other end
does. So the host now shows where it is and the joiner can type it in.
That path is offered always rather than kept back for when discovery has
failed, because discovery here does not fail loudly — it just never
finds anything, and a screen saying only "no games found" leaves nowhere
to go. Hosts take a known port when they can get one, so what gets typed
is an address and not an address and five more digits.
The lobby scrolls instead of filling. Its lazy lists took the whole
screen for a handful of rows and pushed the Start button off the bottom;
they would have done the same to the address box.
Parsing what somebody types off another phone's screen is the one pure
piece of this and is tested, including that what a host displays is
exactly what the other end accepts. The rest needs two phones and a
hotspot, which CI does not have.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
A trick slid off towards its winner and stopped existing. Where it went
was the only record that it had been won at all, and by the third trick
of a hand there was nothing on the table to say who was sitting on what
— which on a real table is the most-glanced-at thing in the game, and
the whole reason cards are gathered rather than binned.
Each player now has a face-down pile in front of them: one card back per
trick, because a trick is squared into a single bundle and put down, and
from above that is what you see. The piles sit beside each player's own
card, one place clockwise along their edge, which is both where a player
would actually put them and the one spot on each edge nothing else was
using. Wizard seats three to six so its piles go half a seat clockwise
round the same ring the cards are played on.
The count follows the cards, not the rules. A trick is counted the
instant its last card is played, which is a beat before the gather and
two before the cards arrive; reading the state directly grew the
winner's pile while the trick was still lying in the middle of the
table, and the cards then slid over and landed on a pile that had
already counted them. rememberArrivedTricks holds the count back until
the sweep has actually run.
The sweep changes to suit its new destination. A gathered card is
squared down towards the size of the bundle it is joining, and stays
whole until the last stretch instead of fading the whole way over: it
now vanishes at the same moment the pile grows by one, and that swap is
only invisible if there was something there to swap.
Kaiser needed a per-seat trick count, which it had never had — it scores
in points, so no rule wanted one. It is defaulted for the wire and
carries a test, because a field no rule depends on is a field that can
quietly stop being maintained. Hearts already keeps the cards themselves
and counts them by the deal.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
President drew only the set on top. Everything played before it was in
the state and simply not on the screen, so beating a pair looked like
the previous pair being taken away and replaced rather than covered.
The pile is the point of the game — what is under the top set is the
history of the round — and it was invisible.
The whole pile is now drawn in one loop keyed by position in it. The top
set is laid out square and side by side, because that is the set that
has to be beaten and the only one that has to be readable; everything
under it is pushed out towards the rim of the ground the top set covers,
so a corner shows. Buried dead centre is buried, and then drawing it
achieves nothing.
Keying by position is what makes the transition work. A superseded set
is the same composable it always was, so when its target moves from a
row slot to the rim it animates from wherever it actually is — no
bookkeeping about where it used to sit, and no card vanishing from the
row to reappear under the heap a frame later. The throw stays relative
to wherever a card belongs, so the two compose: a card lands in its
slot, and is shoved aside from it later without the landing having to
know.
The heap scatter is also now its own pair of bounds, looser than the
table's: eight degrees and fifteen per cent of a card width against four
and a half and five. This was not cosmetic. At the tight bounds a
discard pile of five cards was five cards hidden to within a twentieth
of a card width of each other, which reads as one card — Crazy 8s and
Golf were technically stacking and visibly not. A card keeps whichever
scatter it was given for as long as it lies there, since a card that
shuffled sideways the moment something landed on it would be a card
moving on its own.
Cribbage's series is the same shape of thing and was already keeping its
cards, so it gains the landing to match; its hand gains the tilt the
other hands have.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
Cards appeared and disappeared. A card was drawn at its resting place
the frame it was played, and a finished trick slid off in a straight
line at a constant speed with every card starting and stopping
together. Nothing about that reads as a hand putting a card down.
What a real card does is arrive from the side of the person who threw
it, carry past the spot it was aimed at and rock back, and finish a
little crooked and a little off the mark. That is the whole model. There
are no collisions and no ragdolls, because the difference between cards
on a table and cards thrown down a stairwell is entirely the size of the
numbers: the tilt is capped at four and a half degrees and the drift at
five per cent of a card width, and both are smaller than they want to
be.
The flight is one underdamped spring on a single 0-to-1 value, with
position, angle and height above the table all read off it. The
overshoot is the spring passing 1 and returning, so the card carries and
settles as one motion rather than as three animations that happen to
start together. Driving them apart was tried and looked like it.
The scatter is hashed out of the card's identity rather than drawn from
a generator. A generator would give a card a new angle on every
recomposition, which twitches; hashing the list position instead would
make a card jump whenever another landed beside it. Hashed this way a
card lies the same way for as long as it lies there, and no two cards in
a deck lie the same way as each other. The pure half of that — the
hash, the bounds, the sweep stagger — is unit tested, since it is not
something a screenshot would show.
The sweep now staggers and accelerates: cards break away over the first
third of it rather than in lockstep, each carrying out the same turn it
carried in. Piles are drawn as the last few cards of the heap and stocks
as a pack with depth, so a card played onto a discard pile lands on
something. Every card gains the shadow of a card lying on felt.
Applied to Euchre, Kaiser, Wizard and Hearts at the trick, and to
Crazy 8s, President and Golf at the pile; hands get a tilt small enough
that nobody sees it and large enough that a row of cards stops looking
printed. Motion follows the existing animation-speed setting through a
composition local, so the slider still governs all of it.
Series bumped to 1.3 for the dev channel.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
Holding dice works whatever order they are tapped in, and the hold survives
the throw. Verified on dev by ci.yml runs 118 and 121 and published as
v1.2.123-dev from this tree.
The selection survives a throw and clears on a turn boundary, keyed on the
round as well as the seat so a solo game resets too. Verified by ci.yml
run 121.