LEARNING RECORD · Blackjack Ensemble course

24 July 2026

Prep Session — Wiring Game-Over Payouts (before Ensemble 152)

Date: 2026-07-24 (prep for the 5pm ensemble, "Ensemble 152") Repo: blackjack-ensemble-blue @ 097684d (Ensemble 151) Workspace lesson produced: lessons/0004-paying-the-winners.html


What triggered this record

Lesson 0002's hands-on task (add a two-ace test) went green on arrival — it exercised existing Hand.value() behaviour rather than driving new code. Tom's feedback: "it was helpful to run the test, but it didn't lead to me adding new functionality." Correct critique — that task taught reading, not TDD. The fix: make the next lesson a genuine RED-first cycle tied to the work he'll actually do next session.

The trajectory we found (so lessons aim where the work is)

Reading the repo — not guessing — the next feature is spelled out as pseudocode in GameService.execute() (lines ~93–98):

if (game.isGameOver()) {
    gameMonitor.gameCompleted(game);
    gameRepository.saveOutcome(game);
    // loop through all playerIds in the Game:
        // retrieved PlayerAccount from Repository
        // playerAccount.win(payout, playerOutcome)
        // repository.save(playerAccount)
}

The RED→GREEN cycle — verified empirically (in a throwaway git worktree, then reverted)

Rather than reason about correctness (the mistake that made 0002 feel weak), the whole cycle was run against a scratch git worktree off HEAD, then torn down so the real repo stayed pristine for the ensemble.

RED test added to GameServiceTest (player beats dealer, 2× payoff):

PlayerAccountRepository accounts = PlayerAccountRepository.withNextId(9);
PlayerAccount account = PlayerAccount.register("winner");
account.deposit(50); accounts.save(account);
GameService gameService = GameService.createForTest(new StubShuffler(), accounts);
Deck deck = new StubDeck(Rank.QUEEN, Rank.EIGHT, Rank.TEN, Rank.JACK); // player 20 vs dealer 18
gameService.createGame(List.of(PlayerId.of(9)), new Shoe(List.of(deck)));
gameService.placePlayerBets(List.of(new PlayerBet(PlayerId.of(9), Bet.of(11))));
gameService.initialDeal();
gameService.playerStands();
assertThat(accounts.find(PlayerId.of(9))).get().extracting(PlayerAccount::balance)
        .isEqualTo(50 - 11 + 22); // 61

Failed for the right reason: expected: 61 but was: 39 (bet deducted, payout never added).

GREEN (minimal wiring in execute()):

for (PlayerResult result : game.playerResults()) {
    playerAccountRepository.find(result.playerId()).ifPresent(account -> {
        account.win(result.payout(), result.outcome());
        playerAccountRepository.save(account);
    });
}

→ GameServiceTest 8/8 green; full suite 245/245 green. (Full version with a payout() > 0 ? win : lose branch also verified 245 green.)

Facts pinned down (authoritative, from the code)

Correction (2026-07-24, after Tom reviewed the lesson)

Two things the first cut of Lesson 0004 got wrong, now fixed:

  1. PlayerAccount.win() is already implemented AND unit-testedPlayerAccountTest winEmitsPlayerWonGame + playerWonGameIncreasesBalance. So there is nothing to build/test on the domain win function; the only gap is the wiring in execute(). Don't re-test win through GameService.
  2. A balance assertion cannot force the win-vs-lose branch. Verified: a busting player under win-only code hits win(0, PLAYER_BUSTED)balance += 0 → unchanged, so a balance test passes with no branch. The only thing distinguishing win(0) from lose() is the recorded event (PlayerWonGame vs PlayerLostGame). So the elegant home for the decision is the domain (e.g. PlayerAccount.settle(PlayerResult)), unit-tested via freshEvents(), leaving the service branch-free. The current repo state: execute() has a started loop over game.playerIds() that finds the account and does nothing; the win test is present but @Disabled.

The likely arc tonight (interleaved reds)

  1. win path — balance-driven integration test; GREEN by switching the started loop from game.playerIds() to game.playerResults() and calling win() + save(). (tracer bullet)
  2. win/lose decision — event-driven, NOT balance-driven (see correction #2). Best pushed into the domain as settle(...), unit-tested on freshEvents().
  3. multi-player — two accounts/two outcomes forces placePlayerBets to stop faking with getFirst() and loop all bets; then delete the commented-out // if (playerAccountRepository != null) scaffold (the contract step).

Teaching lessons captured