Repository navigation
Releases: smccloud/Hot-Death-Uno
Release list
1.4.18
The table scales with the window for the first time, cards are sized to the window
rather than to the device's density, and a hand can now be scrolled with a mouse
wheel.
Two issues, one release: #13
did the card sizing and the arrangement, and
#14 did the input model and
the small-window floor. The changelog carries both entries — 1.4.17 and 1.4.18 were
never tagged separately, so they ship together as this.
The table fills the window
GameTable's arrangement has always been window-relative — all four seats, both
piles, the direction colour and the four player indicators are computed from w and
h — but the cards inside it were the card bitmap's dimensions at the device's
density and nothing else, for the life of the process. A 2560x1600 tablet got a
table spread across the panel with 103x160 cards in the middle of it.
Cards are clamped to the window now instead: a card is between 1/22 and 1/10 of the
window's width and between 1/26 and 1/4 of its height. Every phone this app has
shipped on is left exactly where it was, and only the windows the density got wrong
for move:
density window card cards shown per hand
mdpi 1080x1920 52x80 30 (unchanged)
mdpi 1080x2208 55x85 28 (was 52x80, 30)
mdpi 2208x1840 100x154 28 (was 52x80, 59)
mdpi 2560x1600 116x179 23 (was 52x80, 57)
xhdpi 1080x1920 103x160 14 (unchanged)
xhdpi 2560x1600 116x181 23 (was 103x160, 27)
xhdpi 900x700 90x140 10 (was 103x160)
The trade, stated plainly: filling the table costs cards that fit. At mdpi on
2208x1840 the card goes from 52px to 100px and the count from 59 to 28. A hand of
seven to twelve is nowhere near 28, and past the limit the hand is clamped and
scrollable rather than truncated — which is what the wheel below is for.
The arrangement follows the shape of the window
The old orientation test was if (h < 4.5 * m_cardHeight), commented "probably
landscape on a small device", where m_cardHeight comes from resource density: a
threshold of 360px at mdpi and 720px at xhdpi. Measured across phone, tablet and
foldable windows in both orientations at every density, it did not fire for any of
them, so in practice there was no landscape layout at all — and the one case that
did reach it was a 900x700 window, where a landscape arrangement is least useful.
It is an aspect-ratio test now, with near-square as its own arrangement, because a
book-style foldable unfolded is near-square whichever way it is held: 2208x1840 one
way and 1840x2208 the other.
m_maxCardsDisplay also has a floor of one. Both candidate layouts are integer
divisions that go negative on a window too small for either, and everything
downstream is numcards - m_maxCardsDisplay, so zero left the table drawing no
cards at all rather than throwing. Sizing the cards to the window puts that case out
reach; the floor is the belt to those braces.
Scrolling with a wheel
A hand longer than the table can show was drag-only to scroll, which is a poor fit
for the mouse a tablet has: a trackpad cannot hold a button and move at the same
time without being two hands.
GameTable.onGenericMotionEvent reads AXIS_VSCROLL and scrolls the hand under the
pointer. One detent is one card, the same granularity as half a card of drag, and
travel accumulates rather than being truncated per event — a wheel reports a detent
at a time, but a trackpad reports a swipe as a stream of amounts of three or four
per cent, and rounding each event on its own would throw almost every gesture away.
A scroll over bare table is passed up rather than swallowed, so one over the game's
border still reaches whatever is above it. Left click already worked, since a click
is a tap, and it is unchanged.
Two touch bugs the input test turned up
-
A second finger could play a card. The handler compared
event.action, which
packs the pointer index into its high bits, soACTION_DOWNandACTION_UPwere
true only for pointer 0's own press and release. A second finger's press and
release matched no branch and went tosuper— harmless until the ordering turned
it over: one finger resting on a card while a second lands and lifts makes the
first finger's release anACTION_POINTER_UPand the second's a plain
ACTION_UP, and the tap handler then ran on the tail of a gesture whose start it
had never been given. Two thumbs on a tablet, a stylus left resting on the glass,
a hand that shifts while it is held down. There is no two-finger gesture in this
game, so a second pointer now disarms the gesture until everything lifts. -
A cancelled drag was committed by the next tap.
ACTION_CANCELtook down the
long press and nothing else, leaving the drag delta set, so the hand sat shifted
for as long as the table was up — and the next tap'sACTION_UPadded that stale
delta to the offset as well.
Not in this release
- The keyboard. The game is tap and long-press throughout, and there is no
selection or focus concept to hang a key handler on. A decision rather than an
oversight: half-done would be worse than not done. - The secondary mouse button. A non-drag way to scroll a hand is what the wheel
is; a second one would be two ways to do one thing. - Scaling the chrome. The cards scale with the window and the direction arrow,
the four player indicators, the card badge, the emoticons and the winning banner do
not. That is fine at every window the platform will produce — the east indicator's
left edge sits at 325 in a 320px window, well under the 220dp freeform minimum —
and wrong below that. Fixing it means scaling every bitmapinitCardsbuilds,
which is the design pass #13 says somebody has to decide on first. - Hinge awareness. A fold is a
FoldingFeatureand reaches an app only through
androidx.window, which would be the first AndroidX dependency in main source on a
project that is framework-only on purpose. Left as a project decision, on #13.
Verification
Build #137, of the tagged commit, green end to end.
verifyVersionCode: v1.4.18 points at HEAD (1.4.18), treating it as this release being tagged.
verifyVersionCode: 1004018 (1.4.18) > 1004016 (v1.4.16, 1.4.16). OK.
emulator matrix -- tested:[ hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
255 unit tests plus 27 instrumented cases — the 9 instrumented @Test methods
parameterised across the three API levels — 282 in all, none failed or skipped. Nine
tests ran on each emulator level. lintDebug and lintVitalRelease clean.
26 of those 255 are new, and both sets exist because neither #13 nor #14 could
otherwise be checked without a device: GameTableLayoutTest (11) lays a real table
out over ten windows and asserts invariants rather than pixels — every anchor point
inside its window, at least one card fitting, the arrangement matching the shape of
the window, a resize re-laying out without disturbing the round — and
GameTableInputTest (15) draws a real table over a dealt hand and exercises the
wheel and the touch handler, including both bugs above. Every behaviour in both was
checked by breaking it on purpose, and each break fails something specific rather
than everything.
The APK is signed by the release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme, one
signer.
Hot-Death-Uno-v1.4.18.apk
sha256: 6b72538b4efee9ae743d138f65ae00b680e48941e6101d31b6b590d506e27dc0
1.4.16
Every card in the deck is redrawn, and 106 compiler warnings about APIs Android has
deprecated are gone.
Only the art is visible. Nothing about how the game plays, what a card is worth, what
the AI does or what a saved game contains changed.
Every card is redrawn
The card faces and the card back were regenerated as SVG from the card table and the
existing bitmaps, rendered, and the five defects that rendering exposed fixed. Two of
them were showing in every screenshot taken before now:
- The back of a card clipped the Hot Death logo down to an orange sliver along
the top edge. A back now carries the whole logo. - A face-up card drew its label across the big rank — "GLASNOST" through the 2,
"Reverse" through the R — with the digit clipped by the card edge. A face is now a
rank with a small index in each corner, labelled or not.
There is a side effect worth having: the new art compresses far better than the bitmaps
it replaced, so the APK is 3.4 MB smaller — 8.0 MB down to 4.6 MB. Same 692 images
inside, same dex, same manifest; every byte of the difference is card art. The 430 card
files in the source went from 3.9 MB to 673 KB, an average of 9.1 KB down to 1.6 KB.
The deprecation cleanup
PreferenceManager.getDefaultSharedPreferences was reached from 33 places across the
app and the test suite, and the replacement has to name the preferences file itself —
which it now does, in one place, as packageName + "_preferences". Upgraders keep
every setting they have and their saved game; a different file name would have been
an empty file rather than an error.
The one behaviour change worth naming is in the settings screen, which used to ask the
framework to seed its defaults and now walks preferences.xml itself. Defaults are
written per key and never over a value that is already there, so:
- a fresh install gets the same thirteen defaults as before, and
- a preference you have changed is never reset, and
- a preference added to the XML since your last visit now picks up its default, which
the framework's one-per-file marker would have skipped.
The Help and About dialogs are built where they are shown now instead of through
Activity.showDialog, and they behave the same, down to leaving the About text empty
if the version cannot be read rather than printing a stray %s.
The settings screen still uses the framework's android.preference, which Android
deprecated in API 29. The only supported replacement is androidx.preference, and this
app has no third-party dependencies on purpose — the same call made for
WindowInsetsCompat at the API 36 retarget. The warnings are suppressed with that
reason written next to them rather than deleted.
What did not change
No card values, scores, penalties, messages, AI behaviour or save format. Nothing
outside res/drawable-* and the deprecation work touched the game. The four README
screenshots were re-shot from build #128, so the pictures above and below are of the
release artifact rather than of an older build.
Verification
Build #129, of the tagged commit, green end to end.
verifyVersionCode: v1.4.16 points at HEAD (1.4.16), treating it as this release being tagged.
verifyVersionCode: 1004016 (1.4.16) > 1004015 (v1.4.15, 1.4.15). OK.
emulator matrix -- tested:[ hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
229 unit tests plus 27 instrumented cases — the 9 instrumented @Test methods
parameterised across the three API levels — 256 in all, none failed or skipped. Nine
tests ran on each emulator level. Lint and lintVitalRelease clean, and zero
deprecation warnings, down from 106. The release APK is signed by the release key
(CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme, one
signer.
8 of those 229 unit tests are new, covering the rewritten default-seeding, which
had none: it is the one piece of the settings code that can quietly undo your settings
rather than fail anywhere, so the tests pin all thirteen keys, the type each default is
stored as, that a changed value survives, and that a second pass writes nothing.
Hot-Death-Uno-v1.4.16.apk
sha256: de601ec648758eafc9d328ac65b55c4727df3333f9f7a22a0b6bbb26867dd4e4
1.4.15
Four rules questions, answered
Four open questions from the original author's roadmap, decided and implemented. Two
of them changed nothing and are now pinned by tests, which is the point: a rule that
isn't a bug is the easiest kind to change by accident, because nothing goes red.
No card values, scores or messages changed. What changed is when penalties land and who
they land on.
The standardrules cheat had a real bug
Under plain-UNO rules, the victim of a Draw 2 drew two and then played their turn —
and that was the only setting where it happened. It is a strange thing for the setting
whose whole purpose is to be more standard. The victim is now always stepped past.
Draw 2s stack now
A Draw 2 answers another Draw 2, the same card being the only answer either way. That
needed no new rules: a Draw 2 used to draw without ever registering a penalty, and
everything that lets a victim have a turn sits behind that. Routing it like every
other draw card let the existing Draw 4 machinery do the work.
Retaliation does not answer a Draw 2
Retaliation, AIDS and the Holy Defender answer a Wild Draw, a Quitter or a Glasnost —
which is what the card table has always said. Issue #10 claimed otherwise and was
wrong; the code and the card table agreed with each other.
The dealer absorbs a penalty that opens the round
A Draw 2, Quitter, Glasnost or MAD turned face up to start the round now falls on the
dealer: they draw the two, lay their hand face up, remove the next player, or pick a
victim and are ejected along with them. This is what a FIXME at the top of
postDealHands has said since the code was written.
The MAD case is the one to know about: the dealer loses their own round to a card
nobody chose. That is what the card does to whoever plays it, and it is why it needed no
new rules — but it is the one consequence here that could reasonably have been meant
differently.
Also
The README listed the standardrules cheat as a live known bug, claiming its deck
branch was missing. All four rule/deck combinations build correctly and the round-loop
test has been playing two full rounds under the cheat since it was written; the entry
is gone, as was a cheat-table row that described the old turn order as a feature.
Release notes
The Version 1.4.15 section of CHANGELOG.txt, verbatim:
- the four rules questions of issue #10, decided and implemented. Three of the four
were questions and one of those turned out to be a bug
- question 1, no code change: the 1,000-point hand is Quitter and Fuck You, and
Big Brother does not join them. Worth knowing that it does not collide with the
instant-loss rule either -- calculateScore gives a four-bastard hand zero
before calculateValue ever runs, so that hand already scores 0 and not 1,000.
The pair is now pinned by three tests, because a rule that is not a bug is the
easiest kind to change by accident
- question 2a, a bug: under the standardrules cheat -- the one that builds a plain
UNO deck -- the victim of a draw 2 drew two and then played their turn. It was
the only setting in which that happened, which is a strange thing for the
setting whose purpose is to be more standard. assessPenalty advanced past the
victim under a guard that only did so when NOT standard rules, so the guard is
gone and the victim is always stepped past
- question 2b: a draw 2 stacks on a draw 2, which it never could. It used to draw
inline -- handleSpecialCards called forceDraw and advanced the turn itself, and
never touched m_penalty -- and everything that lets a victim *have* a turn
lives in the round loop's checkForDefender gate, which only runs when a penalty
is pending. So a draw 2 now goes through addCardPenalty like every other draw
card, and the machinery that already handled draw four stacking handles this
with no new rules
- checkCard and checkForDefender both gained a draw 2 clause, mirroring the draw
four's. checkCard allows a draw 2 onto a draw 2 outside standard rules and only
once; checkForDefender counts draw 2s, which is what keeps the victim in the
turn that lets them throw one
- question 3, decided against: Retaliation, AIDS and the Holy Defender do NOT
answer a draw 2. checkCard's defender clause lists Wild Draw, Quitter and
Glasnost and has always omitted the draw 2, so issue #10's claim that Retaliation
already applied was wrong and the README's card table was right. Nothing changed
here beyond being pinned
- that leaves the draw 2 with exactly one answer, itself, which is the same shape
the draw four already had. A draw two is the only thing that stops a draw two
- question 4: a penalty card that opens the round is eaten by the dealer, which is
what the FIXME at the top of postDealHands asked for and had done since. The
reading is that the dealer is the *victim* -- the seat the card would have hit
had a player thrown it -- so a draw 2 makes the dealer draw two, a Glasnost
lays their hand face up, a Quitter lets them remove the next player, and a MAD
has them pick somebody and be ejected along with them
- four cards, not "penalties" as a set. A wild can never open a round because the
draw skips them, so there is no wild draw four to absorb; Skip and Reverse are
excluded because they have no victim and the dealer's turn is not in the
rotation, m_startPlayer being getLeftOpp(). Everything goes through
assessPenalty rather than being reimplemented, so an opening draw 2 still
counts the Luck of the Irish and still shares an AIDS penalty
- the MAD case is the one worth staring at: the dealer loses their own round to a
card nobody chose. That is what the card does to whoever plays it, and it is the
reading that needed no new rules -- but it is the part to overrule if it was
meant otherwise
- README: the standardrules cheat was listed as a live known bug, claiming its
deck-construction branch was missing and enumerated 324 cards into an array of
108. All four combinations of standardRules and oneDeck build correctly --
216 and 108 cards, no exception -- and GameRoundLoopTest has been playing two
full rounds under the cheat since it was written. The entry is gone
- the cheat table's row described the old turn order as though it were a feature
("Draw Two no longer grants an extra turn"), which is what the bug above looked
like from the outside
- GameRoundLoopTest.standardRulesDealSevenEach allowed the dealer 9 rather than 7,
and says why in the test. It asserts the deal, and the deal is still 7; the
difference is the opening card's penalty, which is now that test's job rather
than a shuffle-dependent surprise
- PenaltyRulesTest, 18 tests, 221 unit. The suite had nothing that could see any of
this: HandPlayabilityTest covers whether a card is legal and GameRoundLoopTest
asserts invariants about a round running, and neither inspected a decision
- each of the seven behaviours was checked by breaking it on purpose and confirming
something fails. Two attempts did not: routing the draw 2 back to its inline
draw failed nothing at first because the mutation prepended the old code rather
than replacing it, and the checkForDefender clause failed nothing until there was
a test for it. Both are now covered -- the routing by aDrawTwoSetsAPenaltyLike-
EveryOtherDrawCard and the clause by aDrawTwoInHandKeepsItsVictimInTheTurn
- test count is 221 unit plus 9 instrumented
Verification
Build #123, green end to end.
verifyVersionCode: 1004015 (1.4.15) > 1004014 (v1.4.14, 1.4.14). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
248 JUnit cases, 0 failed, 0 skipped — 221 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Nine tests ran
on each emulator level. Lint and lintVitalRelease clean. The release APK is signed by
the release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme, one
signer.
18 of those 221 unit tests are new, because the suite had nothing that could see any
of this: the playability tests cover whether a card is legal and the round-loop tests
assert invariants about a round running, and neither ever inspected a decision. Seven
behaviours were each checked by breaking them on purpose — two of those checks passed
with the behaviour broken and now have tests that do not.
Hot-Death-Uno-v1.4.15.apk
sha256: e9f939a8dc87918d14e562f145c90892d7fd9656293c79c46c48843f80b95516
1.4.14
A sharper Expert seat
Three changes to how the computer plays, all of them in the AI's own scoring and none
of them in the rules. Weak and Strong are untouched — if you play against those,
nothing here will feel different. This is what an Expert seat does differently.
It keeps its wilds
A wild is the highest-leverage card in the deck, and Expert was spending one on the
first legal play that came along. It now scores -60: below the worst any other
card can reach, so a wild goes out only when the hand is otherwise stuck, and held
otherwise.
It will still spend one — on an opponent who is down to their last card, which is the
only place a wild can change the colour at will and the only way to stop somebody
who is about to go out.
It counts what you are holding
The deck is a known list of cards, so the table can work out which colours are
unaccounted for: its own hand and the face-up discard pile subtracted from the deck
leaves the cards that are either in the draw pile or in someone's hand. Expert
charges each colour for what that makes likely, weighted so a player on their last
card costs more than one with a full hand.
So it still leads the colour it holds most of — that maximises what it can follow its
own lead with — but it stops leading the colour the whole table can already match. A
colour it holds nothing of is never named at all.
The estimate tightens as the round runs and stays quiet while the deck is still
mostly unknown, because early on the draw pile holds nearly everything nobody can see
and "that player is probably holding red" is not worth changing a play for.
It breaks ties on balance
Two cards of equal value used to be decided by whichever one the hand happened to
store last, so which of two identical 5s got played was the order of an array. Now
the tie goes to the play that leaves the hand most even, which is the same colour
balance the Expert already weighed — it just gets to choose as well as score.
What this does not do
No card rule, score or message changed, and no other difficulty moved. The tests
below assert decisions on fixed boards; they cannot tell you the seat feels better
over a whole round, which is still worth a game or two at Expert before you trust it.
Release notes
The Version 1.4.14 section of CHANGELOG.txt, verbatim:
- Expert AI, issue #6: hoard wilds, count what the opponents are holding, and
lead a colour chosen against the table rather than against itself. The last of
the original author's stage-7 items
- a wild scores -60 for Strong and above. That is below the worst any other card
can reach -- the Magic 5 is -5, the MAD card can be -20 -- so a wild goes out
when the hand is otherwise stuck and stays in hand otherwise, and above the
-1000 that maxpointval starts at, so a hand of wilds still plays one rather
than drawing. The Java had "if (wild_count < opponent_card_count - 1) testval
= 0" with testval already 0 from its initialiser, so both arms assigned 0 and
the condition chose nothing. Hoarding is what the condition was reaching for.
wild_count went with it: nothing tests it any more
- one exception to the hoard, and it is the reason the old condition mentioned
the opponents' card count at all: an opponent on their last card. A wild is
the only card in the deck that changes the colour at will, so it is the only
way to stop somebody who is about to go out from simply following the lead
- the Mystery wild now falls out of its own ladder onto the hoard score when it
has no opinion. Its comment already said to keep it off non-numbered cards; a
wild on a wild used to score 0 and win a tie against any other worthless card.
The sentence is now the code
- sub-item 2: countUnaccountedInColor works out what each opponent might hold
from the deck, this seat's hand, and the discard pile. It deliberately does not
ask the deck which hand a card is in, because the deck holds references to the
same Card objects the hands do and counting them would be a sharper answer
than a player at the table could have
- sub-item 3: chooseColor's "FIXME -- we need a real strategy" is answered. The
colour a seat holds most of is half a strategy -- it maximises what the seat
can follow its own lead with, and it is all a player watching would see the
Expert doing wrong, since it leads the colour the whole table can already
match. Expert still counts, and still prefers the colour it can keep playing,
but a colour the opponents are likely to hold is now charged for, and a colour
the seat holds nothing of is never named
- the charge is a probability over cards the table cannot account for, with the
draw pile's share discounted by how much of it the pile could hold. That is
what makes it tighten as the round runs and stay quiet while the deck is
mostly unknown: at the top of a round the draw pile holds nearly everything
nobody can see, so "this opponent is probably holding red" is not yet worth
changing a play for. Each opponent is weighted 4/(cards+1), so a player on
their last card costs more than one with a full hand
- two bugs found while writing the tests, both in the new code. The draw-pile
discount was missing its / pool, so it saturated for any draw pile small
enough to matter and read "certain" from the first turn of a round. And a
pigeonhole shortcut I added for opponents holding more cards than there are
other colours turned out to be redundant: the product's own zero factor
already reaches 1.0 there, checked over every pool, inHands and numCards up to
60, so the shortcut went back out
- sub-item 3 also wanted the colour balance to *choose* a lead rather than just
score one. Two cards of equal score are now decided by the play that leaves
the hand most even, so which of two identical cards gets played is no longer
the order of the hand array. Skill 0 and 1 keep >= and the last card wins --
arbitrary, but the behaviour they have always had
- the isFinite check in that tie-break is doing real work.
computeChangeInColorBalance divides by the balance before the play, and a hand
with one card of each colour has a balance of exactly 0, so the result is an
infinity rather than a number. Unguarded, comparisons against it would either
drop every candidate or favour whichever card was scanned first, for no
reason. AnEvenHandFallsBackToHandOrder pins that
- the other three TODO comments in playCard are still there: throwing the mystery
draw on numbered cards, MAD when the hand's points are too high, and offensive
cards against players on low card counts. The first two are done and the
third is now half done by the colour pressure, but they are the author's
notes and rewriting them as if they were a specification would be a claim
about work nobody has done
- AiStrategyTest, 21 tests, 203 unit. The suite had nothing that could see which
card the AI picks -- HandPlayabilityTest covers legality and
GameRoundLoopTest asserts invariants, never decisions -- so every one of these
is a decision a plausible-looking edit would change. The fixture is a real
round off the real 216-card deck, because calculateValue is what supplies the
point values the MAD and Draw Four scores are read from
- each of the eight behaviours was checked by breaking it on purpose and
confirming something fails. That found two tests that pinned nothing: a "the
Expert holds a wild" test that a wild scoring 0 would also have passed, and a
pigeonhole test that passed with the whole colour strategy switched off. The
first is now merged into a test that says plainly it is the weak version, and
the second is gone
- test count is 203 unit plus 9 instrumented
Verification
Build #122, green end to end.
verifyVersionCode: 1004014 (1.4.14) > 1004013 (v1.4.13, 1.4.13). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
230 JUnit cases, 0 failed, 0 skipped — 203 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Nine tests
ran on each emulator level. Lint and lintVitalRelease clean. The release APK is
signed by the release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
21 of those 203 unit tests are new, and they exist because nothing in the suite
could previously see which card the AI picks. Each of the eight behaviours was
checked by breaking it on purpose and confirming a test fails; two tests that pinned
nothing were found that way and one was removed.
Hot-Death-Uno-v1.4.14.apk
sha256: 6c99d7f3cb228fd3b1592c7c74e39e54f61983bf9c1164b5a9590586e41e2298
1.4.13
Novice mode: play at your own pace
A setting that pauses the game after each play and waits for a tap, instead of a
timed delay. Off by default, beside Game speed in the settings.
The whole implementation is one branch in waitABit. That is deliberate: the timed
pause reaches play from everywhere — every message that waits, and four
deliberate think-pauses inside ComputerPlayer — so one branch means all of them
follow. Including the pauses between two computer players, which is what following
the game actually means.
Very fast still wins
Game speed: Very fast returns before the tap wait, so the one setting whose
entire purpose is not to wait doesn't get turned into one that does. Asked for
both, you get the faster one.
The tap is its own thing
It does not go through the existing draw-pile handler, and must not. That method
clears the round-start wait and then, if the current player is human, tells
them they have decided to draw a card. Reusing it would hand the player a decision
they never made, on a turn that hasn't started. So there is a separate
Game.tableTapped with no side effect beyond the wait, and the table's touch
handler calls it first — before the pile and hand handling — so one tap can
legitimately do both things.
Backgrounding the app mid-pause
This is the part the issue was right to worry about. The wait is a spin of
Thread.sleep(100) on the game thread, so an unbounded one is a shutdown hazard:
leave the app mid-pause and the thread sits there.
Two exits, both tested by releasing a wait that is provably already blocked:
pause()clears the flag — the ordinary case, and the player is leaving- the loop checks
m_stoppingevery pass — covers everything else
Resuming deliberately does not demand a tap for a pause you never saw the end of.
Release notes
The Version 1.4.13 section of CHANGELOG.txt, verbatim:
- novice mode: pause after each play and wait for a tap instead of a timed
delay, so a beginner can follow the game rather than race it. Off by default,
beside game_speed in the settings. Fixes #5, the last open item of the
Java-to-Kotlin migration's stage 7
- it is a checkbox rather than another value on game_speed. game_speed is a
number of milliseconds and two readers want exactly that -- waitABit, and
GameTable's toast duration -- so a "tap to advance" value would have to be
special-cased in both and the toast would get a made-up duration out of it.
The choice between the two kinds of pause is the checkbox
- the branch is in waitABit, not at each call site. The timed pause reaches
play from everywhere: every promptUser that waits, and four deliberate
think-pauses in ComputerPlayer. One branch means all of them follow,
including the pauses between two computer players, which is what following
the game actually means
- Very fast still wins. getDelay() of 0 returns before the tap wait, so the
one setting whose purpose is not to wait is not turned into a setting that
does. That ordering is a test
- the tap does not go through drawPileTapped, and must not. That method clears
the round-start wait and then, if the player is human, calls
turnDecisionDrawCard -- so reusing it would hand the player a draw decision
they never made, on a turn that has not started. There is a separate
Game.tableTapped with no side effect beyond the wait, called from
GameTable.onTouchEvent before the pile and hand handling so the same touch
can do both things
- a wait that can wait for ever is a shutdown hazard, and this one could. It is
a spin of Thread.sleep(100) on the game thread, so backgrounding the app
mid-pause would leave it sitting there. Two exits, both tested by releasing a
wait that is provably already blocked: pause() clears the flag, which is the
ordinary case, and the loop checks m_stopping, which covers the rest.
Resuming does not demand a tap for a pause the player never saw the end of
- new NoviceModeTest, 11 tests. The waits run on a background thread, because
blocking is the behaviour, and each one waits for waitingToAdvance() before
asserting it is still blocked, so no test can pass by the wait never having
started. Disabling the waitABit branch fails 5 of the 11
- one test was wrong on the first pass and the correction is the useful part:
it asserted that a draw-pile tap releases a novice wait, which it does not --
the app calls tableTapped first and then the pile handling. Split into what
actually holds: a draw-pile tap alone does not release it, and the two calls
in the order onTouchEvent makes them both release and decide the draw
- test count is 182 unit plus 9 instrumented
Verification
Build #121, green end to end.
verifyVersionCode: 1004013 (1.4.13) > 1004012 (v1.4.12, 1.4.12). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
209 JUnit cases, 0 failed, 0 skipped — 182 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Nine tests
ran on each emulator level. Lint and lintVitalRelease clean. The release APK is
signed by the release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
No card rule, score or message changed. This release adds a setting and a
wait; if a game plays differently, it isn't because of this.
Hot-Death-Uno-v1.4.13.apk
sha256: 48dacbda485ff062fbe2e146f70bf915bada134a96883c38a747a1413e12bd88
1.4.12
Nothing in the app changed, and that is worth saying plainly
This release exists for the build tooling and the documentation. The APK differs
from 1.4.11's in one entry out of 745, and that entry is
AndroidManifest.xml:
identical entries : 744 (including classes.dex)
differing entries : AndroidManifest.xml
So classes.dex is byte-identical to the last release. Nothing was recompiled
differently, nothing was stripped differently, no resource changed. If you are
already on 1.4.11 there is no reason to install this one.
What did change
./jenkins.sh now takes --screenshots and --no-screenshots, which
jenkins.ps1 already had. The README documented only the PowerShell flag as the
way to regenerate the screenshots, which is not runnable on a Linux checkout — so
the documented way to do the thing the README says the screenshots are for did
not run where most of the people reading this repository are.
./jenkins.sh build --no-tests --screenshots
The flag is tri-state, like --tests/--no-tests: sent only when asked for, so
the job's own default is the default. The screenshots section also stops claiming
a curl-and-crumb incantation that nobody should have had to write.
And jenkins.sh's header said it "stays untracked". It stopped doing that a
long time ago, as did jenkins.ps1. A comment like that reads as a reason not to
commit changes to the file, which is the opposite of what is true.
Release notes
The Version 1.4.12 section of CHANGELOG.txt, verbatim:
- jenkins.sh takes --screenshots and --no-screenshots, so the local client can
ask for the README screenshots on a Linux checkout the way jenkins.ps1
already could on Windows. The README documented only the PowerShell flag,
which meant the documented way to regenerate the images was not runnable here;
that section now gives both, and the curl-and-crumb workaround it carried in
between has gone
- both flags are tri-state, like --tests/--no-tests: sent only when asked for,
so the job's own default really is the default and a controller that has
changed its default can still be overridden in both directions. jenkins.ps1
has -Screenshots with no -NoScreenshots, which is enough there because the
job's own default is false; this side sends an explicit false too
- jenkins.sh's own header said it "stays untracked", which it stopped doing a
long time ago, as had jenkins.ps1. Left in place it would read as a reason not
to commit changes to the file, which is the opposite of what is true. Corrected
to say what is untracked, which is the credentials
- no application code changed and no build behaviour changed. Test count is
unchanged at 171 unit plus 9 instrumented
- the version is bumped even so, and this reverses the choice made for issue #8,
where a Jenkinsfile-only change was left at the previous versionCode. Bumping
is better: a release APK built from main at a versionCode that is already
published will not install over the published app, which is a real annoyance
for anyone testing a build from main rather than a theoretical tidiness
concern. Leaving main to drift at a published versionCode defers that to
whoever remembers
Verification
Build #120, green end to end.
verifyVersionCode: 1004012 (1.4.12) > 1004011 (v1.4.11, 1.4.11). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
198 JUnit cases, 0 failed, 0 skipped — 171 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Nine tests
ran on each emulator level, which build #112's check confirms rather than assumes.
Lint and lintVitalRelease clean.
This is not build #119. That build produced an identical APK from the same commit
but was queued with --no-tests, so no unit test, no lint and no emulator ran in
it. Shipping it would have meant a release whose notes could not say any of the
above, so it was left alone and #120 was run instead.
The release APK is signed by the release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
Hot-Death-Uno-v1.4.12.apk
sha256: 5f81d47e2f6352cfb05c865f75510cfa6abdaf8229556a004867fca2ba1259cd
1.4.11
Nothing changed, and that is the release
Issue #7 asked whether the fourth computer seat taking North's difficulty was
deliberate. It was, so this release documents it rather than changing it — plus
the one place the answer was missing where it actually reaches a player.
Nothing about the game changed. Same three difficulty pairs, same seats, same
fall-through. If you play 1.4.11 and something feels different, it isn't.
Three pairs, because there are three opponents
The settings screen has three skill/aggression pairs, under categories titled
West, North and East — the three seats a computer holds in a normal game.
South is the human's.
So the else branch in ComputerPlayer.readAggressionAndSkill isn't a setting
that ran out. It's South — the seat you would occupy — playing when
computer_4th replaces you. That is what it always was; the comment just claimed
a test affordance, and an intent that reads as a scratchpad reads as an
accident to whoever finds it next.
The alternative wasn't free. A fourth pair is two more keys in Prefs, a fourth
PreferenceCategory in preferences.xml, a player4 title, and four difficulty
controls on the main settings screen — permanently — for a mode that leaves no
human in the game at all. A game with four computers is a simulation, and
difficulty knobs for a simulation are not a setting anybody asked to remove.
computer_4th is off by default and its summary has always said "mostly useful
for testing", so this is an affordance that is reachable, not one that's hidden.
What was actually wrong
The settings screen didn't say. Someone who ticked Computer 4th saw three
difficulty sections and no way to know the fourth seat was reading one of them.
It now reads:
Have a computer play the 4th (south) seat at the North player's difficulty;
mostly useful for testing, since it leaves no human in the game
One thing left alone on purpose
The else stays an else rather than becoming an explicit SEAT_SOUTH test. A
seat of 0 would land there too, and refusing to set anything would leave the
class defaults instead — a different behaviour to reason about in a path that
can't currently be reached, since Game's constructors call setSeat on all four
seats before any of them plays. Noted at the method.
Release notes
The Version 1.4.11 section of CHANGELOG.txt, verbatim:
- the 4th computer seat reads North's difficulty on purpose, and now says so
where it matters. ComputerPlayer's else branch fell through to p2 under a
comment that described a testing affordance "if we are testing", which reads as
an accident to whoever finds it next; issue #7 asked whether it was deliberate
and, if so, for a comment that claimed it. It is deliberate. Fixes #7
- why there is no fourth pair, since that is the part the old comment did not
say: the settings screen has three difficulty pairs under categories titled
West, North and East because those are the three seats a computer holds in a
normal game. South is the human's. The else branch is South -- the seat the
human would occupy -- playing when computer_4th replaces them. A fourth pair
would mean two more keys in Prefs, a fourth category in preferences.xml, and
four difficulty controls on the main settings screen for a mode that leaves no
human in the game at all. A game with four computers is a simulation, and
difficulty knobs for a simulation are not a setting anybody asked for
- the preference summary said nothing about which seat the 4th computer is or
what it plays at, so somebody who turned the option on had three difficulty
sections and no way to know the fourth seat was reading one of them. It now
says the South seat plays at the North player's difficulty, and that the
option leaves no human in the game. The README's settings table says the same
- the else is left as an else rather than an explicit SEAT_SOUTH test. A seat of
0 would land there too, and refusing to set anything would leave the class
defaults instead, which is a different behaviour to reason about in a path
that cannot currently be reached: Game's constructors call setSeat on all four
seats before any of them plays. Worth knowing if that ever changes
- new ComputerDifficultyTest, 4 tests. Each preference pair is given a distinct
skill and aggression (0/-6, 1/0, 2/6, all from the ranges in arrays.xml) so a
seat reading the wrong pair fails on both halves rather than passing because
two settings happened to agree, and so a reordered comparison in the if-chain
shows up as a copy of the wrong pair while every other test still passed.
southReadsNorths is the one that fails if a fourth pair is ever added, which
is the point: that should be a deliberate act with preferences.xml behind it,
not something to arrive at by editing an else
- no behaviour changed. Same three pairs, same seats, same fall-through
- test count is 171 unit plus 9 instrumented
Verification
Build #114, green end to end.
verifyVersionCode: 1004011 (1.4.11) > 1004010 (v1.4.10, 1.4.10). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
198 JUnit cases, 0 failed, 0 skipped — 171 unit plus 27 instrumented cases. Nine
tests ran on each emulator level, which build #112's check confirms rather than
assumes. Lint and lintVitalRelease clean. The release APK is signed by the
release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
Hot-Death-Uno-v1.4.11.apk
sha256: 0db6a8fc7bf7b1be8335de3039b3e2c323b7ae8d213da701254848f9f36d8da9
1.4.10
The game now says which card you played, and the labels had a space missing
Two things, one of which was hiding behind the other.
An accepted play said nothing
A rejected card has said "That card's no good" for years. An accepted one said
nothing at all, so on a small table a legal play and a tap that never registered
looked the same. The turn now says:
That card's no good
South plays Green 5
No "another" variant. This release was going to say "North threw another
green 5", which needs a judgement about what "another" means — a second
consecutive match of the colour, or an exact repeat of the card. checkCard
returns a boolean, not a reason, so either reading would infer why the play
was legal from state the game never recorded. Confirming the play registered is
the gap that was actually open. Explaining the legality is a bigger piece of work
and is not in here.
The descriptor is Game.cardToString, reused rather than written fresh — it
already rendered in a sentence, so "North draws Green 5" has been on the table
all along and the two now agree on capitalisation.
The labels had no space in them
Worth its own heading, because it was in the app the whole time and nobody had
noticed: the card help dialog's title, every log line for a play, and the draw
message all rendered "Green5".
The separator was meant to be the trailing space on the four cardcolor_*
strings. aapt2 strips it at build time. Checked against the release APK
rather than inferred:
resources.arsc "Green " : False
resources.arsc "Green" : True
The comment in toString claimed that space was the separator, and that adding
another was what used to double-space every label. It read as deliberate.
Nothing doubled up, because there was nothing there to double. toString owns
the join now.
CardTextTest was agreeing with the defect. Five of its expectations read
the colour from that same stripped resource, so they passed while the labels were
wrong. Its closing note predicted this precisely:
strip the trailing space out of strings.xml and every one of them still passes,
leaving "Blue7"
and the only guard it had was for a double space. The missing counterpart —
everyColouredCardIsTwoWords, asserting one space and not two — is there now.
Release notes
The Version 1.4.10 section of CHANGELOG.txt, verbatim:
- an accepted card now says so. A rejected one has said "That card's no good"
since 1.4.x and an accepted one said nothing at all, so on a small table a
legal play was indistinguishable from a tap that never registered. The human
player's turn now reports "South plays Green 5" from the same branch that
reports the refusal. Fixes #4
- no "another" variant, which the issue proposed as "North threw another green
5". It needs a judgement about what "another" means -- a second consecutive
match of the colour, or an exact repeat of the card -- and checkCard returns a
boolean rather than a reason, so either reading would infer why the play was
legal from state the game never recorded. The message confirms the play
registered, which is the gap. Explaining the legality is a separate and larger
piece of work
- the descriptor is Game.cardToString, which the issue proposed writing from
scratch. It already existed and was already rendering in a sentence:
msg_player_draws_specific_card has said "North draws Green 5" since the card
help was added. Reusing it is why the two sentences sit side by side on the
table with the same capitalisation. The colour is capitalised to match, rather
than lower-cased as the issue wrote it
- BUG FIX, and it is the reason the message reads properly. Card labels had no
space in them: the card help dialog's title, every log line for a play, and
the draw message all said "Green5". The separator was the trailing space on the
four cardcolor_* strings, and that space does not survive the build -- aapt2
strips it, and the compiled resources.arsc contains "Green" with no space. The
comment in toString said the trailing space was the separator and that adding
another was what used to double-space every label; it read as deliberate, and
nothing doubled up because there was nothing there to double. toString now
owns the join itself
- CardTextTest had five expectations that read the colour from the same stripped
resource the code did, so they agreed with the defect rather than the intent
and passed while it happened. Its closing note predicted this exactly --
"strip the trailing space out of strings.xml and every one of them still
passes, leaving Blue7" -- and the only guard it added was for a double space.
The missing counterpart is there now, everyColouredCardIsTwoWords, asserting
one space and not two, which is what a dropped join fails
- new CardPlayMessageTest, 5 tests. This is the first test that watches a message
arrive rather than checking what the code formats: it drives
HumanPlayer.turnDecisionPlayCard and reads what reached the table, because the
gap was that nothing was emitted at all and only an emitted message shows that.
It lays a GameTable out by hand, because Toast positions itself off
m_ptMessages and that is only built in onSizeChanged, and it leaves
fastForward off -- promptUser returns immediately when it is on, which would
make the test pass without a message ever being sent. The refusal path, the
card-not-in-hand path and the flags the round loop reads are pinned alongside
- test count is 167 unit plus 9 instrumented
Verification
Build #113, green end to end.
verifyVersionCode: 1004010 (1.4.10) > 1004009 (v1.4.9, 1.4.9). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
194 JUnit cases, 0 failed, 0 skipped — 167 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Nine tests
ran on each emulator level, which build #112's new check confirms rather than
assumes. Lint and lintVitalRelease clean. The release APK is signed by the
release key (CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
Nothing about the rules changed. No card, score, penalty or turn order was
touched; this release adds one message on a path that was silent and fixes the
rendering of card names. If a label looks different, it is the space.
Note that fastForward suppresses every message, new one included — pre-existing
behaviour, unchanged here, and the reason the new test deliberately leaves it off.
Hot-Death-Uno-v1.4.10.apk
sha256: 0f5009e82624302df6e11565703a082d82890c38235c62bda8f8f644ede0c62e
1.4.9
Penalty messages now say what you have to do
A player waiting their turn used to read "North threw a Draw Four - 4 to
South..." — which puts them in the back half of the sentence, having to work out
whether they are the South in it. It now reads:
South draws 4 cards (Draw Four from North)...
All eleven msg_penalty_* strings. Ten carry a count; the eleventh is Mystery
Draw onto a non-number, which now says the thing that actually mattered:
South draws nothing (Mystery Draw on a non-number)
The old wording described the thrower and never said the victim was fine.
What stayed, and why
First and stacked are still separate strings, because they say different
things about the same numbers. Penalty.addCards overwrites the generating
player on every call, so on a stack the name is whoever stacked, not whoever
started it — and that is what the wording now says:
South draws 4 cards (Draw Four from North)...
South draws 8 cards (Draw Four stacked by North)...
Whoever threw the first card is not recoverable once a second card lands on it.
Nothing on Penalty still holds it. m_origCard looks like it might — it is
the field that survives a null argument — but Game.handleSpecialCards passes
m_currCard at all six call sites, so it is overwritten on the same call as
everything else. That is why the stacked wording is "stacked by" and not "from".
The counts, in one place
The six bare literals at the addCards call sites are now Penalty.COUNT_*,
beside the PENTYPE_* constants. Nothing tied those numbers to what the player
was told, so a count could change in one place and not the other.
The count in the message is read back off the penalty rather than passed in,
so a stacked message reports the running total by construction instead of by
agreement between two numbers that happen to match.
They are not on Card: the same Draw Four costs 4 as a penalty and 50 points as
a hand, and both numbers are true and unrelated.
Smaller things, in passing
The five penalty branches were five copies of the same four lines — add, test for
a stack, choose between two resources, format three arguments — differing only
in the card's worth and its two strings. That is one function now.
And first-or-stacked is decided from the penalty's count before the add. The
four fixed cards used to test afterwards, as getNumCards() > 4 and so on, which
only works because each of them adds exactly its own worth every time. Mystery
Draw is worth whatever number it covers, so it already had to check beforehand —
which is why the other four can now.
Release notes
The Version 1.4.9 section of CHANGELOG.txt, verbatim:
- penalty messages now name the player who has to act, not the one who threw the
card. They read "North threw a Draw Four - 4 to South...", which asks a player
mid-turn to work out who owes what from the back half of the sentence. They now
read "South draws 4 cards (Draw Four from North)...". All eleven msg_penalty_*
strings, not the ten with a count: the eleventh is Mystery Draw onto a
non-number, which says so now -- "South draws nothing (Mystery Draw on a
non-number)". The old wording described the thrower and never said the victim
was fine. Fixes #3
- the first and stacked variants stay separate, because they say different things
about the same numbers. Penalty.addCards overwrites the generating player on
every call, so on a stack the name is whoever stacked rather than whoever
started it: "South draws 8 cards (Draw Four stacked by North)". That is why the
stacked wording says "stacked by" and not "from". Whoever threw the first card
is not recoverable once a second lands on it -- nothing on Penalty still holds
it, including m_origCard, which is overwritten on the same call
- the card counts moved out of bare literals at the six addCards call sites in
Game.handleSpecialCards into Penalty.COUNT_DRAWFOUR and friends, alongside the
PENTYPE_* constants. Nothing tied those six numbers to what the player was told,
so a count could change in one place and not the other; and the counts are now
read back off the penalty for the message, so the number shown is the running
total by construction rather than by agreement. Not on Card deliberately: the
same Draw Four costs 4 as a penalty and 50 points as a hand, and both are true
- the five penalty branches were five copies of the same four lines -- add, test
for a stack, pick between two resources, format three arguments -- differing
only in the card's worth and its two strings. That is one function now,
addCardPenalty. The Delayed Blast branch is why it takes the thrower as an
argument: it advances the turn before adding, so its generator is not the player
on turn
- first-or-stacked is now read from the penalty's count before the add. The four
fixed cards tested afterwards, as getNumCards() > 4 and so on, which is only
equivalent because each adds exactly its own worth every time; Mystery Draw is
worth whatever number it covers, so it already had to do it this way and is the
reason the other four can now
- new PenaltyMessageTest, 8 tests. Every message is formatted from the resource
rather than written out again, so editing strings.xml -- the whole change here --
is what the tests see; a test holding the sentences would have passed anyway.
The argument specifiers are checked against the raw template as well as by
formatting, because String.format ignores surplus arguments silently and would
let a stray %4$s through to throw on a penalty card in front of a player
- test count is 161 unit plus 9 instrumented
Verification
Build #110, green end to end.
verifyVersionCode: 1004009 (1.4.9) > 1004008 (v1.4.8, 1.4.8). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
188 JUnit cases, 0 failed, 0 skipped — 161 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Lint and
lintVitalRelease clean. The release APK is signed by the release key
(CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
No card rule, score or penalty arithmetic changed. PenaltyTest and the
instrumented PenaltyStackTest both still pass, which is the check that a message
change did not quietly move a count — three of the new tests also fail against the
old wording, so the rewrite is pinned rather than asserted.
Hot-Death-Uno-v1.4.9.apk
sha256: d8dcf58ff2d4e3436faef188e4e3c701e50b72714718356668a91c464a72ad73
1.4.8
A malformed save is rejected where it is read, not on the first card played
A corrupt save used to resume successfully and crash later, with the save file
nowhere in the stack trace. The player is told, too.
Game's resuming constructor caught JSONException and did nothing with it, so
a well-formed JSON object missing any one key returned a Game that had stopped
part-way through the parse — m_penalty and m_currCard still null. The resume
looked like it had worked. The crash came on the first card played, off one of
the 68 m_penalty!! sites in Game.kt, and by then the save file was not in the
stack trace at all. "The game resumed" and "the game half-resumed" are the same
thing from outside, and the only signal was a NullPointerException some way into
play.
The fix is two lines
catch (e: JSONException)
{
Log.e("HDU", "Could not resume the saved game: " + e.message, e)
throw e
}GameActivity.onCreate already had the fallback — if (m_game == null) m_game = Game(this, m_go), with a comment saying it existed for a game state that would
not parse. It was dead code: the constructor never propagated the exception, so
m_game was never null and the branch never fired. Rethrowing is what makes it
live, which is why this needed no new error path.
The log line matters as much as the throw. org.json names the key it could
not find, so Could not resume the saved game: No value for penalty is the
difference between a discarded save that is a mystery and one a bug report can
be made from.
The player is told
Discarding the save was already the right call; doing it silently was not. A new
game looks exactly like a resumed one, so their saved game disappeared with no
sign it had ever been there. A Toast says so before play starts — LENGTH_LONG,
because the default is short enough to miss while the table is being laid out,
and this is the only notice they get.
A Toast rather than a dialog, deliberately: a modal would have to be dismissed
before play, to communicate the one thing where no action is available. The save
is gone and a new game is already built.
The unreadable string is cleared from preferences as well. onPause writes a
fresh snapshot, but only once this activity has been paused — and until then
Main.onResume keeps offering "Continue" for a string known to be unreadable, so
backing out and coming straight back in reproduces the same failure and the same
toast with nothing new to show for it.
Release notes
The Version 1.4.8 section of CHANGELOG.txt, verbatim:
- a malformed save is now rejected at the point it is read, rather than
crashing some way into play. Game's resuming constructor caught JSONException
and did nothing with it, so a well-formed JSON object missing any one key
returned a Game that had stopped part-way through the parse: m_penalty and
m_currCard were still null, and the crash came later, on the first card
played, off one of the 68 m_penalty!! sites in Game.kt. Nothing in the stack
trace pointed at the save file, because by then the save file was not in it.
The catch logs and rethrows, so GameActivity's existing "if (m_game == null)"
fallback starts a new game -- which is what the comment there already claimed
happened, and which was dead code for as long as the catch swallowed the
exception. Fixes issue #2
- the log line is the point as much as the throw is. org.json names the key it
could not find, so "Could not resume the saved game: No value for penalty" is
the difference between a discarded save that is a mystery and one a bug
report can be made from. The player still gets a new game either way; a
discarded save is otherwise silent data loss
- the m_penalty comment above the field said the resuming constructor left it
null on a malformed save and pointed at issue #2. It now says what is true,
which is that the plain constructor is the only path that leaves it null, and
that is the NullPointerException the Java threw before startRound()
- new MalformedSaveTest, 6 tests. It removes each of the 6 top-level keys, each
of the 7 inside "state" and each of the 6 inside a player, and asserts every
one is rejected -- the empty catch treated all of them identically, so testing
one would not have covered the rest. Two of the cases are worth their own
test: a save with no "players" key fails before anything is built, while a
player missing "totalScore" fails only after the deck, both piles and all
four seats have been constructed with wires running back to this Game, so it
got the furthest before throwing
- the good path is pinned too. aCompleteSaveStillResumes builds a save the app
could have written -- a deck, a two-card draw pile, four hands of two cards
each -- and asserts the cards came back, the dealer and current player exist,
and seat 0's right-hand opponent is seat 1. Without it a fix that rejected
every save would pass the other five tests
- the player is now told. A discarded save was previously invisible from the
table: the new game looks exactly like a resumed one, so their saved game
disappeared with no sign it had ever been there. A Toast says so before play
starts, LENGTH_LONG because the default is short enough to miss while the
table is being laid out. A Toast rather than a dialog on purpose -- a modal
would have to be dismissed before play, to say the one thing there is no
action available for
- the unreadable save is dropped rather than left for the next launch to fail
on again. onPause writes a fresh snapshot, but only once this activity has
been paused, and until then Main.onResume keeps offering "Continue" for a
string known to be unreadable -- so a player who backs out and comes straight
back in gets the same failure and the same toast with nothing new to show for
it. Clearing the preference is the point at which we know it cannot be read
- five more tests, on what the player sees rather than on what the constructor
does. Four of them drive the real GameActivity.onCreate -- the constructor
tests cannot reach the toast or the preference, because onCreate is where both
live. anUnreadableSaveFallsBackToANewGame asserts getDeck() is null, which is
the plain constructor's state and therefore says the fallback was taken; a
resumed game has a deck even when the rest of the parse failed. It does not
assert the absence of a crash, because the game thread is not running and
checkCard is never reached -- the absence would have passed either way
- aGoodSaveIsResumedAndKept is the control for all four: a good save is still
resumed and still in preferences, so a fix that cleared "gamestate"
unconditionally would fail it
- test count is 153 unit plus 9 instrumented
Verification
Build #108, green end to end.
verifyVersionCode: 1004008 (1.4.8) > 1004007 (v1.4.7, 1.4.7). OK.
emulator matrix -- tested:[hot-death-uno-api34 hot-death-uno-api35 hot-death-uno-api36] skipped:[] failed:[] known-failing:[]
180 JUnit cases, 0 failed, 0 skipped — 153 unit plus 27 instrumented cases, the 9
instrumented @Test methods being parameterised across the API levels. Lint and
lintVitalRelease clean. The release APK is signed by the release key
(CN=Shaun McCloud, SHA-256
2bcfd49088b570021065d6a360c86337620d5234efad878aa7acc58b53338fcb), v2 scheme,
one signer.
Nothing else in the app changed. No card rule, score, message or penalty
behaviour was touched; this release changes what happens when a save cannot be
read, and nothing else. 1.4.7 changed what the green 3 costs, so between them
there is one thing worth playing a long game to check.
Hot-Death-Uno-v1.4.8.apk
sha256: 892a22490decc570b3b07c5887af577775faa87f1eacf9f4d41e453a60dd3a8c