diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c93626..4905c31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,8 +6,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v6 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install PlatformIO @@ -20,7 +20,7 @@ jobs: run: pio run - name: Test game logic run: | - g++ -std=c++17 -Wall -Wextra -Werror -Iinclude test/puzzle_logic_test.cpp -o /tmp/puzzle_logic_test + g++ -std=c++17 -Wall -Wextra -Werror -Iinclude -Icore/include test/puzzle_logic_test.cpp -o /tmp/puzzle_logic_test /tmp/puzzle_logic_test - g++ -std=c++17 -Wall -Wextra -Werror -Iinclude test/mastermind_logic_test.cpp -o /tmp/mastermind_logic_test + g++ -std=c++17 -Wall -Wextra -Werror -Iinclude -Icore/include test/mastermind_logic_test.cpp -o /tmp/mastermind_logic_test /tmp/mastermind_logic_test diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/editor.xml b/.idea/editor.xml new file mode 100644 index 0000000..fef590b --- /dev/null +++ b/.idea/editor.xml @@ -0,0 +1,350 @@ + + + + + \ No newline at end of file diff --git a/.idea/jsonSchemas.xml b/.idea/jsonSchemas.xml new file mode 100644 index 0000000..a6d4e70 --- /dev/null +++ b/.idea/jsonSchemas.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b3560b1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,24 @@ + + + + + + {} + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Build_CYD_no_upload.xml b/.idea/runConfigurations/Build_CYD_no_upload.xml new file mode 100644 index 0000000..f384b31 --- /dev/null +++ b/.idea/runConfigurations/Build_CYD_no_upload.xml @@ -0,0 +1,27 @@ + + + + diff --git a/.idea/runConfigurations/Flash_CYD_Monitor.xml b/.idea/runConfigurations/Flash_CYD_Monitor.xml new file mode 100644 index 0000000..397dfe6 --- /dev/null +++ b/.idea/runConfigurations/Flash_CYD_Monitor.xml @@ -0,0 +1,25 @@ + + + + diff --git a/.idea/runConfigurations/Flash_CYD_both_boards.xml b/.idea/runConfigurations/Flash_CYD_both_boards.xml new file mode 100644 index 0000000..bbd12d9 --- /dev/null +++ b/.idea/runConfigurations/Flash_CYD_both_boards.xml @@ -0,0 +1,26 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Monitor_Both_CYDs.xml b/.idea/runConfigurations/Monitor_Both_CYDs.xml new file mode 100644 index 0000000..c2eefdf --- /dev/null +++ b/.idea/runConfigurations/Monitor_Both_CYDs.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/runConfigurations/Monitor_CYD_USB0.xml b/.idea/runConfigurations/Monitor_CYD_USB0.xml new file mode 100644 index 0000000..a9a0167 --- /dev/null +++ b/.idea/runConfigurations/Monitor_CYD_USB0.xml @@ -0,0 +1,25 @@ + + + + diff --git a/.idea/runConfigurations/Monitor_CYD_USB1.xml b/.idea/runConfigurations/Monitor_CYD_USB1.xml new file mode 100644 index 0000000..8ffabb3 --- /dev/null +++ b/.idea/runConfigurations/Monitor_CYD_USB1.xml @@ -0,0 +1,25 @@ + + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/content/conundrum/default_words.csv b/content/conundrum/default_words.csv new file mode 100644 index 0000000..0321d7b --- /dev/null +++ b/content/conundrum/default_words.csv @@ -0,0 +1,50 @@ +BLUEPRINT|TNPULEBRI|A detailed plan or design +COUNTDOWN|DTWNOCNUO|A backward sequence before an event +STARTLING|GTLNRSAIT|Surprising or astonishing +CARPETING|TICNGPEAR|Floor covering material +GRENADIER|AIGNRDERE|A type of soldier +PETROLEUM|MOUERTPLE|Crude oil and its derivatives +RECTANGLE|CTRELAGNE|A four-sided flat shape +IMPORTANT|TNAMIPORT|Of great significance +SPECTATOR|POSTCATER|Someone watching an event +TRANSPORT|RNOTTARPS|Moving people or goods +CARNATION|TONRAANIC|A type of flower +CELEBRATE|EBTELRACE|To mark a happy occasion +DEMANDING|DEMNIGDAN|Requiring great effort +ELABORATE|EOBARATEL|Detailed and complicated +FORMATION|IOATONMRF|An arrangement or pattern +GREATNESS|ESNAERTGS|The quality of being great +HAPPINESS|NASEPHSIP|The state of being happy +IDENTICAL|TCALDENII|Exactly the same +MILESTONE|EMILESOTN|A significant achievement +NIGHTMARE|HIRMNGETA|A very frightening dream +ORCHESTRA|OCHRTRASE|A large group of musicians +PARACHUTE|PHATACRUO|Device for safe descent +QUALITIES|SQUITAILE|Characteristics or features +RASPBERRY|BSYRARPRE|A red or black berry +SCULPTURE|LCETRSUPU|A three-dimensional artwork +TESTIMONY|STIOYMNTE|A formal statement +UNCERTAIN|NCTEUNRAI|Not definitely known +VALENTINE|VLANITEEN|A token of love or affection +WATERFALL|LTFEARAWL|Water falling from a height +YESTERDAY|SEATDREHY|The day before today +ZEALOUSLY|LOUZSYEAL|With great enthusiasm +AFTERMATH|RFTHMAAET|Consequences of an event +BRILLIANT|BTRANLILI|Very bright or intelligent +CHEMISTRY|RHMYSTICE|The science of substances +DIALOGUES|GODSILEAU|Conversations in text +ELEPHANTS|PHANTELSE|Large grey mammals +FIREWORKS|RFISWOKRE|Explosive displays of light +GEOGRAPHY|PHYROEAGP|The study of the earth +HURRICANE|NARRIHUCE|A violent tropical storm +INVISIBLE|SINIBVILE|Unable to be seen +JUDGEMENT|JDTUGEMEN|An opinion or decision +LABYRINTH|NLBHYTIRA|A maze or complex system +MECHANISM|MHCNIAMSE|A system of moving parts +NOTORIOUS|TOUNORISE|Well known for a bad reason +OPERATION|PNOOAIRET|A process or procedure +PARCHMENT|PRACMNHET|A writing material +QUARTERLY|TAYRLEQU|Happening four times a year +RELENTLESS|SLNLEETER|Without stopping or weakening +SUBMARINE|BNMAURIES|An underwater vessel +TORMENTED|DTRMONETE|Caused to suffer greatly diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 7762e60..d2d94be 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,7 +1,5 @@ -/** - * Flip7 Core CMake Build - * Shared C++ library for protocol, game logic, and reconciliation - */ +# Flip7 Core CMake Build +# Shared C++ library for protocol, game logic, and reconciliation cmake_minimum_required(VERSION 3.16) project(flip7core LANGUAGES CXX) @@ -18,11 +16,14 @@ add_library(flip7core STATIC src/mastermind.cpp src/reconciliation.cpp src/active_game.cpp + src/countdown_engine.cpp ) target_include_directories(flip7core PUBLIC + $ $ + $ $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -35,9 +36,9 @@ target_compile_options(flip7core PRIVATE ) # Install headers -install(DIRECTORY include/flip7/ - DESTINATION include/flip7 - FILES_MATCHING PATTERN "*.hpp" +install(DIRECTORY include/ + DESTINATION include + FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" ) install(TARGETS flip7core @@ -57,8 +58,7 @@ if(BUILD_TESTS) tests/protocol_test.cpp tests/serialization_test.cpp tests/reconciliation_test.cpp - tests/mastermind_test.cpp - tests/puzzle_test.cpp + tests/countdown_test.cpp ) target_link_libraries(flip7core_tests PRIVATE @@ -70,22 +70,23 @@ if(BUILD_TESTS) add_test(NAME flip7core_tests COMMAND flip7core_tests) endif() -# Package config -include(CMakePackageConfigHelpers) -write_basic_package_version_file( - ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfigVersion.cmake - VERSION 1.0.0 - COMPATIBILITY SameMajorVersion -) +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/flip7coreConfig.cmake.in") + include(CMakePackageConfigHelpers) + write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfigVersion.cmake + VERSION 1.0.0 + COMPATIBILITY SameMajorVersion + ) -configure_package_config_file( - cmake/flip7coreConfig.cmake.in - ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfig.cmake - INSTALL_DESTINATION lib/cmake/flip7core -) + configure_package_config_file( + cmake/flip7coreConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfig.cmake + INSTALL_DESTINATION lib/cmake/flip7core + ) -install(FILES - ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfig.cmake - ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfigVersion.cmake - DESTINATION lib/cmake/flip7core -) \ No newline at end of file + install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/flip7coreConfigVersion.cmake + DESTINATION lib/cmake/flip7core + ) +endif() diff --git a/core/include/flip7/conundrum_table.hpp b/core/include/flip7/conundrum_table.hpp new file mode 100644 index 0000000..cf25a66 --- /dev/null +++ b/core/include/flip7/conundrum_table.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace flip7::countdown { + +struct ConundrumEntry { + const char* solution; + const char* scramble; + const char* hint; +}; + +extern const ConundrumEntry kConundrumTable[]; +extern const std::size_t kConundrumTableSize; + +} // namespace flip7::countdown diff --git a/core/include/flip7/countdown_engine.hpp b/core/include/flip7/countdown_engine.hpp new file mode 100644 index 0000000..484fb79 --- /dev/null +++ b/core/include/flip7/countdown_engine.hpp @@ -0,0 +1,523 @@ +/** + * Open-Source Countdown Engine - Core Header + * Platform-neutral C++ implementation of Countdown game rules (Numbers, Letters, Conundrum) + * and match-level score management. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace flip7::countdown { + +enum class RoundType : uint8_t { + Numbers = 1, + Letters = 2, + Conundrum = 3, +}; + +enum class ArithmeticOp : uint8_t { + Add = 1, + Subtract = 2, + Multiply = 3, + Divide = 4, +}; + +enum class CommandType : uint8_t { + // Numbers round + NumSelectLargeCount = 1, // chooser picks 0-4 large numbers + NumSubmitClaim = 2, // player submits private achieved value + NumPresentStepRequest = 3, // presenter requests one arithmetic step + NumPresentBankResult = 4, // presenter banks current value as available + NumPresentUndo = 5, // presenter undoes last step + NumPresentComplete = 6, // presenter ends presentation + // Letters round + LetPickVowel = 7, // chooser draws a vowel + LetPickConsonant = 8, // chooser draws a consonant + LetSubmitClaim = 9, // player submits claimed word length (1-9) + LetPresentTileTap = 10, // presenter taps a tile by index (0-8) + LetPresentBackspace = 11, // presenter removes last tile + LetPresentComplete = 12, // presenter submits final word + LetVerifyWord = 13, // observer votes: payload[0] 1=accepted 0=rejected + // Conundrum round + ConSubmitAttempt = 14, // player submits full 9-letter answer +}; + +enum class EventType : uint8_t { + RoundStarted = 1, + NumbersPuzzleReady = 2, + NumClaimReceived = 3, + NumClaimsRevealed = 4, + NumStepAccepted = 5, + NumStepRejected = 6, + NumPresentationComplete = 7, + LetterDrawn = 8, + LetterSelectionComplete = 9, + LetClaimReceived = 10, + LetClaimsRevealed = 11, + LetWordAccepted = 12, + LetWordRejected = 13, + LetPresentationComplete = 14, + ConAttemptCorrect = 15, + ConAttemptIncorrect = 16, + ConTimeout = 17, + RoundEnded = 18, +}; + +enum class MatchPhase : uint8_t { + Setup = 0, + InRound = 1, + BetweenRounds = 2, + MatchComplete = 3, +}; + +// Fixed-capacity vector — no heap growth after construction; safe on ESP32. +template +class FixedVector { +public: + FixedVector() : data_{}, size_(0) {} + bool push_back(const T& v) { + if (size_ >= N) return false; + data_[size_++] = v; + return true; + } + bool pop_back() { + if (size_ == 0) return false; + --size_; + return true; + } + T& back() { return data_[size_ - 1]; } + [[nodiscard]] const T& back() const { return data_[size_ - 1]; } + T& operator[](size_t i) { return data_[i]; } + const T& operator[](size_t i) const { return data_[i]; } + [[nodiscard]] size_t size() const { return size_; } + [[nodiscard]] bool empty() const { return size_ == 0; } + [[nodiscard]] bool full() const { return size_ >= N; } + void clear() { size_ = 0; } + T* begin() { return data_; } + T* end() { return data_ + size_; } + [[nodiscard]] const T* begin() const { return data_; } + [[nodiscard]] const T* end() const { return data_ + size_; } +private: + T data_[N]; + size_t size_; +}; + +struct CalculationStep { + int32_t operandA{0}; + ArithmeticOp op{ArithmeticOp::Add}; + int32_t operandB{0}; + int32_t result{0}; +}; + +struct NumberWorkspace { + std::optional currentValue; + FixedVector availableValues; + FixedVector history; + + void reset(const std::vector& tiles) { + currentValue.reset(); + availableValues.clear(); + for (auto t : tiles) availableValues.push_back(t); + history.clear(); + } +}; + +struct NumberPlayerState { + bool claimSubmitted{false}; + int32_t claimedValue{0}; + bool presentationComplete{false}; + bool presentationValid{false}; + int32_t demonstratedValue{0}; + FixedVector steps; + NumberWorkspace workspace; +}; + +struct PlayerState { + uint32_t boardId{0}; + int32_t score{0}; + bool isHost{false}; +}; + +struct RoundResult { + RoundType roundType{RoundType::Numbers}; + uint32_t winnerBoardId{0}; + bool isTie{false}; + std::map scoreAwards; +}; + +struct CommandContext { + uint32_t senderBoardId{0}; + uint32_t timestampMs{0}; +}; + +struct CommandResult { + bool accepted{false}; + std::string errorCode; + std::string errorMessage; + + CommandResult() = default; + CommandResult(bool acceptedValue, std::string errorCodeValue, + std::string errorMessageValue) + : accepted(acceptedValue), errorCode(std::move(errorCodeValue)), + errorMessage(std::move(errorMessageValue)) {} +}; + +struct EventEnvelope { + uint32_t sequenceNumber{0}; + uint32_t roundId{0}; + EventType eventType; + uint32_t senderBoardId{0}; + std::string payload; +}; + +// Versioned puzzle descriptor — sent by host; guest verifies checksum before use. +struct PuzzleDescriptor { + uint32_t roundSeed{0}; + uint16_t rulesetVersion{0}; + uint16_t contentPackVersion{0}; + uint32_t puzzleChecksum{0}; +}; + +// Display projections — plain POD for safe copying across tasks. +struct NumbersRoundProjection { + uint32_t target{0}; + int32_t tiles[6]{0}; + uint8_t tileCount{0}; +}; + +struct LettersRoundProjection { + char letters[9]{0}; + uint8_t letterCount{0}; +}; + +struct ConundrumRoundProjection { + char solution[10]{0}; + char scramble[10]{0}; + char hint[48]{0}; + bool solved{false}; + bool expired{false}; + uint32_t winnerBoardId{0}; +}; + +// Abstract Random Source Interface for Deterministic Testing +class IRandomSource { +public: + virtual ~IRandomSource() = default; + virtual uint32_t next() = 0; + virtual uint32_t nextRange(uint32_t min, uint32_t max) = 0; +}; + +// Simple Pseudo-Random Implementation +class SeededRandomSource : public IRandomSource { +public: + explicit SeededRandomSource(uint32_t seed = 12345) : state_(seed) {} + uint32_t next() override { + state_ = state_ * 1664525u + 1013904223u; + return state_; + } + uint32_t nextRange(uint32_t min, uint32_t max) override { + if (min >= max) return min; + return min + (next() % (max - min + 1)); + } +private: + uint32_t state_; +}; + +// Returns a deterministic SeededRandomSource for a given round. +// Both boards call this with the same inputs to generate identical puzzle content. +// seed = gameId ^ (roundNumber * 2654435761u) — Knuth multiplicative hash +inline SeededRandomSource makeRoundRandom(uint32_t gameId, uint32_t roundNumber) { + return SeededRandomSource(gameId ^ (roundNumber * 2654435761u)); +} + +// Round plugin interface — platform-neutral; engine holds one active round. +class IRound { +public: + virtual ~IRound() = default; + [[nodiscard]] virtual RoundType roundType() const = 0; + virtual CommandResult applyCommand(const CommandContext& ctx, + CommandType type, + const std::vector& payload) = 0; + virtual std::optional tryFinalize( + const std::vector& playerBoardIds) = 0; +}; + +// Numbers Round State & Logic +class NumbersRound : public IRound { +public: + NumbersRound(uint32_t target, const std::vector& tiles); + + static NumbersRound createRandom(IRandomSource& random, uint8_t largeCount); + + [[nodiscard]] uint32_t target() const { return target_; } + [[nodiscard]] const std::vector& tiles() const { return tiles_; } + + // Score table: exact=10, within 5=7, within 10=5, else 0. + static int32_t calculateScore(uint32_t target, uint32_t achieved); + + // Returns true if claimedValue can be reached from tiles_ using + // +, -, *, / operations (each tile used at most once). + [[nodiscard]] static bool isValueReachable(int32_t target, + const std::vector& tiles); + + // Private claim — stored by boardId; not revealed until both submitted. + // Validates that claimedValue is reachable from tiles_; rejects unreachable + // values with CLAIM_UNREACHABLE. + CommandResult submitClaim(uint32_t boardId, int32_t claimedValue); + [[nodiscard]] bool bothClaimsSubmitted() const; + [[nodiscard]] int32_t claimFor(uint32_t boardId) const; + + // Presentation step — host validates; ContinueWithResult leaves result as + // currentValue; BankResult returns it to the available pool for branching. + CommandResult applyStep(uint32_t boardId, ArithmeticOp op, + int32_t operandA, int32_t operandB, + bool bankResult = false); + CommandResult undoStep(uint32_t boardId); + CommandResult completePresentation(uint32_t boardId); + + [[nodiscard]] const NumberPlayerState* playerState(uint32_t boardId) const; + [[nodiscard]] NumbersRoundProjection projection() const; + + // IRound + [[nodiscard]] RoundType roundType() const override { return RoundType::Numbers; } + CommandResult applyCommand(const CommandContext& ctx, CommandType type, + const std::vector& payload) override; + std::optional tryFinalize( + const std::vector& playerBoardIds) override; + +private: + uint32_t target_{0}; + std::vector tiles_; + std::map playerStates_; +}; + +// Letters Round State & Logic +class LettersRound : public IRound { +public: + LettersRound() = default; + + // Picking phase — called by host; enforces 3-5 vowel / 4-6 consonant constraints. + bool drawVowel(IRandomSource& random); + bool drawConsonant(IRandomSource& random); + [[nodiscard]] bool canDrawVowel() const; + [[nodiscard]] bool canDrawConsonant() const; + + [[nodiscard]] const std::string& drawnLetters() const { return drawnLetters_; } + [[nodiscard]] size_t letterCount() const { return drawnLetters_.size(); } + [[nodiscard]] size_t vowelCount() const { return vowelCount_; } + [[nodiscard]] size_t consonantCount() const { return consonantCount_; } + [[nodiscard]] bool isSelectionComplete() const { return drawnLetters_.size() >= 9; } + + bool syncDrawnLetters(const std::string& letters); + + static bool isValidSubset(const std::string& word, + const std::string& availableLetters); + + // Claim phase — private word-length claim per player. + CommandResult submitClaim(uint32_t boardId, uint8_t claimedLength); + [[nodiscard]] bool bothClaimsSubmitted() const; + [[nodiscard]] uint8_t claimFor(uint32_t boardId) const; + + // Presentation phase — presenter taps tiles by letter index (0-8). + CommandResult presentTileTap(uint32_t boardId, uint8_t tileIndex); + CommandResult presentBackspace(uint32_t boardId); + CommandResult presentComplete(uint32_t boardId); + CommandResult verifyWord(uint32_t verifierBoardId, bool accepted); + + [[nodiscard]] const std::string& presentedWordFor(uint32_t boardId) const; + [[nodiscard]] bool verificationFor(uint32_t boardId) const; + [[nodiscard]] LettersRoundProjection projection() const; + + // IRound + [[nodiscard]] RoundType roundType() const override { return RoundType::Letters; } + CommandResult applyCommand(const CommandContext& ctx, CommandType type, + const std::vector& payload) override; + std::optional tryFinalize( + const std::vector& playerBoardIds) override; + + [[nodiscard]] RoundResult evaluate(const std::vector& playerBoardIds) const; + +private: + std::string drawnLetters_; + size_t vowelCount_{0}; + size_t consonantCount_{0}; + std::map claims_; + std::map presentedWords_; + std::map verifications_; + bool complete_{false}; +}; + +// Conundrum Round State & Logic +// Simultaneous attempt with bounded buzz-window; both players may attempt +// until the deadline expires or the conundrum is solved. +class ConundrumRound : public IRound { +public: + ConundrumRound(std::string solution, std::string scramble, + std::string hint); + + // Pick a random conundrum from the built-in content table. + static ConundrumRound createRandom(IRandomSource& random); + + [[nodiscard]] const std::string& solution() const { return solution_; } + [[nodiscard]] const std::string& scramble() const { return scramble_; } + [[nodiscard]] const std::string& hint() const { return hint_; } + + // Set the submission deadline (absolute ms timestamp). + void setDeadlineMs(uint32_t deadlineMs) { deadlineMs_ = deadlineMs; } + [[nodiscard]] uint32_t deadlineMs() const { return deadlineMs_; } + [[nodiscard]] bool hasDeadlineElapsed(uint32_t nowMs) const { + return deadlineMs_ > 0 && nowMs >= deadlineMs_; + } + + // Simultaneous attempt — checked immediately; no turn-based locking. + // Returns accepted=true only when attempt matches solution. + // Returns DEADLINE_ELAPSED if the buzz-window has expired. + CommandResult submitAttempt(uint32_t boardId, const std::string& attempt); + + [[nodiscard]] bool isSolved() const { return solved_; } + [[nodiscard]] bool isExpired() const { return expired_; } + void markExpired(); + [[nodiscard]] uint32_t winnerBoardId() const { return winnerBoardId_; } + [[nodiscard]] ConundrumRoundProjection projection() const; + + // IRound + [[nodiscard]] RoundType roundType() const override { return RoundType::Conundrum; } + CommandResult applyCommand(const CommandContext& ctx, CommandType type, + const std::vector& payload) override; + std::optional tryFinalize( + const std::vector& playerBoardIds) override; + +private: + std::string solution_; + std::string scramble_; + std::string hint_; + bool solved_{false}; + bool expired_{false}; + uint32_t deadlineMs_{0}; + uint32_t winnerBoardId_{0}; +}; + +// Overall Match Manager +class CountdownMatchEngine { +public: + CountdownMatchEngine(uint32_t hostBoardId, uint32_t guestBoardId, + uint32_t gameId); + + [[nodiscard]] uint32_t gameId() const { return gameId_; } + [[nodiscard]] uint32_t hostBoardId() const { return hostBoardId_; } + [[nodiscard]] uint32_t guestBoardId() const { return guestBoardId_; } + // Score leader — owns authoritative game state. + [[nodiscard]] uint32_t hostPlayerId() const { return hostBoardId_; } + // Previous round winner — picks the next round type. + [[nodiscard]] uint32_t chooserBoardId() const { return chooserBoardId_; } + [[nodiscard]] uint32_t hostTerm() const { return hostTerm_; } + [[nodiscard]] MatchPhase phase() const { return phase_; } + [[nodiscard]] uint32_t roundNumber() const { return roundNumber_; } + + [[nodiscard]] const PlayerState& hostPlayer() const { return hostPlayer_; } + [[nodiscard]] const PlayerState& guestPlayer() const { return guestPlayer_; } + + [[nodiscard]] bool isHost(uint32_t boardId) const { return boardId == hostBoardId_; } + [[nodiscard]] bool isChooser(uint32_t boardId) const { return boardId == chooserBoardId_; } + + // Convenience snapshot for UI rendering. + struct MatchState { + PlayerState hostPlayer; + PlayerState guestPlayer; + MatchPhase phase{MatchPhase::Setup}; + uint32_t roundNumber{0}; + uint32_t chooserBoardId{0}; + uint32_t hostTerm{1}; + }; + [[nodiscard]] MatchState matchState() const { + return MatchState{hostPlayer_, guestPlayer_, phase_, + roundNumber_, chooserBoardId_, hostTerm_}; + } + + // Reset to a fresh Setup state for a new host/guest pairing. + void resetMatch(uint32_t hostBoardId, uint32_t guestBoardId) { + hostBoardId_ = hostBoardId; + guestBoardId_ = guestBoardId; + chooserBoardId_ = hostBoardId; // host chooses the first round + hostTerm_ = 1; + hostPlayer_ = PlayerState{}; + hostPlayer_.boardId = hostBoardId; + hostPlayer_.isHost = true; + guestPlayer_ = PlayerState{}; + guestPlayer_.boardId = guestBoardId; + phase_ = MatchPhase::Setup; + roundNumber_ = 0; + activeRound_.reset(); + } + + // Reconcile authority and match metadata received from the wire without + // discarding an already-synchronized active round. + void syncWireState(uint32_t hostBoardId, uint32_t guestBoardId, + uint32_t chooserBoardId, uint32_t hostTerm, + uint32_t roundNumber, int32_t hostScore, + int32_t guestScore) { + hostBoardId_ = hostBoardId; + guestBoardId_ = guestBoardId; + chooserBoardId_ = chooserBoardId; + hostTerm_ = hostTerm; + roundNumber_ = roundNumber; + hostPlayer_.boardId = hostBoardId; + hostPlayer_.isHost = true; + hostPlayer_.score = hostScore; + guestPlayer_.boardId = guestBoardId; + guestPlayer_.isHost = false; + guestPlayer_.score = guestScore; + } + + // Create and store the active round object; advance to InRound. + void startNextRound(RoundType type, IRandomSource& random, + uint8_t largeCount = 2); + + // Active round access — nullptr when no round is in progress. + IRound* activeRound() { return activeRound_.get(); } + [[nodiscard]] const IRound* activeRound() const { return activeRound_.get(); } + [[nodiscard]] RoundType activeRoundType() const { return activeRoundType_; } + + // Round-type-specific accessors — nullptr when a different round type is active. + // Uses static_cast guarded by activeRoundType_ (no RTTI / dynamic_cast needed). + NumbersRound* numbersRound(); + [[nodiscard]] const NumbersRound* numbersRound() const; + LettersRound* lettersRound(); + [[nodiscard]] const LettersRound* lettersRound() const; + ConundrumRound* conundrumRound(); + [[nodiscard]] const ConundrumRound* conundrumRound() const; + + // Display projections — safe to copy across tasks; return empty structs when + // no round of that type is active. + [[nodiscard]] NumbersRoundProjection numbersProjection() const; + [[nodiscard]] LettersRoundProjection lettersProjection() const; + [[nodiscard]] ConundrumRoundProjection conundrumProjection() const; + + // Apply score updates; update hostBoardId (score leader) and chooserBoardId + // (round winner) independently per ADR-005. + void recordRoundResult(const RoundResult& result); + + [[nodiscard]] bool isMatchComplete() const { return phase_ == MatchPhase::MatchComplete; } + +private: + uint32_t gameId_{0}; + uint32_t hostBoardId_{0}; + uint32_t guestBoardId_{0}; + uint32_t chooserBoardId_{0}; + uint32_t hostTerm_{1}; + PlayerState hostPlayer_; + PlayerState guestPlayer_; + MatchPhase phase_{MatchPhase::Setup}; + uint32_t roundNumber_{0}; + RoundType activeRoundType_{RoundType::Numbers}; + std::unique_ptr activeRound_; +}; + +} // namespace flip7::countdown diff --git a/core/include/flip7/protocol.hpp b/core/include/flip7/protocol.hpp index 2c20262..3e9cd32 100644 --- a/core/include/flip7/protocol.hpp +++ b/core/include/flip7/protocol.hpp @@ -24,6 +24,9 @@ constexpr size_t kStateRequestPacketSize = 32; constexpr size_t kGameStatePacketSize = 236; constexpr size_t kGameAckPacketSize = 40; constexpr size_t kMastermindStateRequestPacketSize = 32; +constexpr size_t kCountdownStatePacketSize = 128; +constexpr size_t kCountdownAckPacketSize = 40; +constexpr size_t kCountdownStateRequestPacketSize = 32; // Game limits constexpr uint8_t kPuzzleMaxTiles = 30; @@ -80,6 +83,11 @@ enum class MessageType : uint8_t { MastermindFullState = 8, MastermindAck = 9, MastermindRequestState = 10, + // Countdown + CountdownState = 11, + CountdownFullState = 12, + CountdownAck = 13, + CountdownRequestState = 14, // BLE-specific extensions (100+) BleCommand = 100, BleEvent = 101, @@ -107,6 +115,7 @@ enum class ActiveGameKind : uint8_t { Home = 0, Puzzle = 1, Mastermind = 2, + Countdown = 3, }; enum class PacketOrigin : uint8_t { @@ -231,6 +240,79 @@ struct MastermindStateRequestPacket { uint32_t stateDigest; }; +// Countdown types +constexpr uint8_t kCountdownMaxTiles = 6; +constexpr uint8_t kCountdownMaxSteps = 16; +constexpr uint8_t kCountdownMaxLetters = 9; + +enum class CountdownRoundType : uint8_t { + None = 0, + Numbers = 1, + Letters = 2, + Conundrum = 3, +}; + +enum class CountdownPhase : uint8_t { + Setup = 0, + InRound = 1, + BetweenRounds = 2, + MatchComplete = 3, +}; + +struct CountdownPlayerState { + int32_t score{0}; +}; + +struct CountdownMatchState { + uint32_t gameId{0}; + uint32_t hostBoardId{0}; + uint32_t guestBoardId{0}; + uint32_t chooserBoardId{0}; + uint32_t hostTerm{1}; + uint32_t roundNumber{0}; + CountdownPhase phase{CountdownPhase::Setup}; + CountdownRoundType activeRoundType{CountdownRoundType::None}; + CountdownPlayerState hostPlayer; + CountdownPlayerState guestPlayer; + uint8_t reserved[6]{0}; +}; + +struct CountdownRoundState { + CountdownRoundType type{CountdownRoundType::None}; + int32_t numbersTarget{0}; + int32_t numbersTiles[kCountdownMaxTiles]{0}; + uint8_t numbersTileCount{0}; + char letters[kCountdownMaxLetters]{0}; + uint8_t letterCount{0}; + char conundrumScramble[kCountdownMaxLetters + 1]{0}; + char conundrumHint[48]{0}; + bool conundrumSolved{false}; + uint8_t reserved[5]{0}; +}; + +struct CountdownStatePacket { + PacketHeader header; + CountdownMatchState matchState; + CountdownRoundState roundState; +}; + +struct CountdownAckPacket { + PacketHeader header; + uint32_t targetBoardId; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; + MessageType acknowledgedType; + uint8_t reserved[3]; +}; + +struct CountdownStateRequestPacket { + PacketHeader header; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; +}; + // Active game clock struct ActiveGameClock { uint32_t epoch; diff --git a/core/include/game_selection.h b/core/include/game_selection.h index 14ded20..91e5680 100644 --- a/core/include/game_selection.h +++ b/core/include/game_selection.h @@ -9,6 +9,7 @@ enum class ActiveGameKind : uint8_t { Home = 0, Puzzle, Mastermind, + Countdown, }; struct ActiveGameClock { @@ -46,8 +47,9 @@ inline bool shouldSendActiveGameState( inline uint32_t nextActiveGameEpoch(uint32_t activeEpoch, uint32_t puzzleEpoch, - uint32_t mastermindEpoch) { + uint32_t mastermindEpoch, + uint32_t countdownEpoch = 0) { const uint32_t latest = - std::max(activeEpoch, std::max(puzzleEpoch, mastermindEpoch)); + std::max({activeEpoch, puzzleEpoch, mastermindEpoch, countdownEpoch}); return latest == UINT32_MAX ? kNoActiveGameEpoch : latest + 1; } diff --git a/core/src/active_game.cpp b/core/src/active_game.cpp new file mode 100644 index 0000000..aea9f92 --- /dev/null +++ b/core/src/active_game.cpp @@ -0,0 +1,13 @@ +/** + * Flip7 Core - Active Game Clock + * Manages game epoch and active game state + */ + +#include "flip7/protocol.hpp" + +namespace flip7 { + +// This file provides the implementation for functions declared in protocol.hpp +// The actual implementations are in the inline functions in protocol.hpp + +} // namespace flip7 \ No newline at end of file diff --git a/core/src/countdown_engine.cpp b/core/src/countdown_engine.cpp new file mode 100644 index 0000000..7946f64 --- /dev/null +++ b/core/src/countdown_engine.cpp @@ -0,0 +1,868 @@ +/** + * Open-Source Countdown Engine - Implementation + */ + +#include "flip7/countdown_engine.hpp" +#include "flip7/conundrum_table.hpp" +#include +#include +#include +#include +#include +#include +#include + + +namespace flip7::countdown { + +// -------------------------------------------------- +// NumbersRound +// -------------------------------------------------- + +NumbersRound::NumbersRound(uint32_t target, const std::vector& tiles) + : target_(target), tiles_(tiles) {} + +NumbersRound NumbersRound::createRandom(IRandomSource& random, uint8_t largeCount) { + const uint32_t target = random.nextRange(101, 999); + + std::vector largePool = {25, 50, 75, 100}; + std::vector smallPool = {1, 1, 2, 2, 3, 3, 4, 4, 5, 5, + 6, 6, 7, 7, 8, 8, 9, 9, 10, 10}; + + const uint8_t largeToPick = std::min(largeCount, 4); + const auto smallToPick = static_cast(6 - largeToPick); + + std::vector selectedTiles; + + for (uint8_t i = 0; i < largeToPick && !largePool.empty(); ++i) { + const size_t idx = + random.nextRange(0, static_cast(largePool.size() - 1)); + selectedTiles.push_back(largePool[idx]); + largePool.erase(largePool.begin() + static_cast(idx)); + } + + for (uint8_t i = 0; i < smallToPick && !smallPool.empty(); ++i) { + const size_t idx = + random.nextRange(0, static_cast(smallPool.size() - 1)); + selectedTiles.push_back(smallPool[idx]); + smallPool.erase(smallPool.begin() + static_cast(idx)); + } + + return {target, selectedTiles}; +} + +int32_t NumbersRound::calculateScore(uint32_t target, uint32_t achieved) { + uint32_t diff = target > achieved ? (target - achieved) : (achieved - target); + if (diff == 0) return 10; + if (diff <= 5) return 7; + if (diff <= 10) return 5; + return 0; +} + +bool NumbersRound::isValueReachable(int32_t target, + const std::vector& tiles) { + if (tiles.empty()) return false; + if (tiles.size() == 1) return tiles[0] == target; + + for (size_t i = 0; i < tiles.size(); ++i) { + for (size_t j = i + 1; j < tiles.size(); ++j) { + int32_t a = tiles[i]; + int32_t b = tiles[j]; + std::vector remaining; + for (size_t k = 0; k < tiles.size(); ++k) { + if (k != i && k != j) remaining.push_back(tiles[k]); + } + + // a + b + remaining.push_back(a + b); + if (isValueReachable(target, remaining)) return true; + remaining.pop_back(); + + // a - b (positive only) + if (a > b) { + remaining.push_back(a - b); + if (isValueReachable(target, remaining)) return true; + remaining.pop_back(); + } + // b - a (positive only) + if (b > a) { + remaining.push_back(b - a); + if (isValueReachable(target, remaining)) return true; + remaining.pop_back(); + } + + // a * b + remaining.push_back(a * b); + if (isValueReachable(target, remaining)) return true; + remaining.pop_back(); + + // a / b (exact division only) + if (b != 0 && a % b == 0) { + remaining.push_back(a / b); + if (isValueReachable(target, remaining)) return true; + remaining.pop_back(); + } + // b / a (exact division only) + if (a != 0 && b % a == 0) { + remaining.push_back(b / a); + if (isValueReachable(target, remaining)) return true; + remaining.pop_back(); + } + } + } + return false; +} + +CommandResult NumbersRound::submitClaim(uint32_t boardId, int32_t claimedValue) { + auto& ps = playerStates_[boardId]; + if (ps.claimSubmitted) { + return CommandResult{false, "ALREADY_CLAIMED", "Claim already submitted"}; + } + if (!isValueReachable(claimedValue, tiles_)) { + return CommandResult{false, "CLAIM_UNREACHABLE", + "Claimed value is not achievable from the drawn tiles"}; + } + ps.claimedValue = claimedValue; + ps.claimSubmitted = true; + ps.workspace.reset(tiles_); + return CommandResult{true, "", ""}; +} + +bool NumbersRound::bothClaimsSubmitted() const { + return playerStates_.size() >= 2 && + std::all_of(playerStates_.begin(), playerStates_.end(), + [](const auto& entry) { + return entry.second.claimSubmitted; + }); +} + +int32_t NumbersRound::claimFor(uint32_t boardId) const { + auto it = playerStates_.find(boardId); + return (it != playerStates_.end()) ? it->second.claimedValue : 0; +} + +CommandResult NumbersRound::applyStep(uint32_t boardId, ArithmeticOp op, + int32_t operandA, int32_t operandB, + bool bankResult) { + auto it = playerStates_.find(boardId); + if (it == playerStates_.end() || !it->second.claimSubmitted) { + return CommandResult{false, "NO_CLAIM", "No active claim for this player"}; + } + NumberWorkspace& ws = it->second.workspace; + + // Validate operand A is available + bool aFound = false; + if (ws.currentValue.has_value() && ws.currentValue.value() == operandA) { + aFound = true; + } else { + for (const int32_t availableValue : ws.availableValues) { + if (availableValue == operandA) { aFound = true; break; } + } + } + // Validate operand B is available (distinct from A) + bool bFound = false; + for (const int32_t availableValue : ws.availableValues) { + if (availableValue == operandB) { bFound = true; break; } + } + if (!aFound || !bFound) { + return CommandResult{false, "OPERAND_UNAVAILABLE", + "One or both operands are not available"}; + } + + // Compute result + int32_t result = 0; + switch (op) { + case ArithmeticOp::Add: result = operandA + operandB; break; + case ArithmeticOp::Subtract: result = operandA - operandB; break; + case ArithmeticOp::Multiply: result = operandA * operandB; break; + case ArithmeticOp::Divide: + if (operandB == 0 || operandA % operandB != 0) { + return CommandResult{false, "INVALID_DIVISION", + "Division must yield a positive integer"}; + } + result = operandA / operandB; + break; + } + if (result <= 0) { + return CommandResult{false, "NON_POSITIVE", "Result must be positive"}; + } + + // Consume operand A from currentValue or availableValues + if (ws.currentValue.has_value() && ws.currentValue.value() == operandA) { + ws.currentValue.reset(); + } else { + for (size_t i = 0; i < ws.availableValues.size(); ++i) { + if (ws.availableValues[i] == operandA) { + ws.availableValues[i] = ws.availableValues.back(); + ws.availableValues.pop_back(); + break; + } + } + } + // Consume operand B from availableValues + for (size_t i = 0; i < ws.availableValues.size(); ++i) { + if (ws.availableValues[i] == operandB) { + ws.availableValues[i] = ws.availableValues.back(); + ws.availableValues.pop_back(); + break; + } + } + + CalculationStep step{operandA, op, operandB, result}; + it->second.steps.push_back(step); + ws.history.push_back(step); + + if (bankResult) { + ws.availableValues.push_back(result); + ws.currentValue.reset(); + } else { + ws.currentValue = result; // ContinueWithResult: left operand for next step + } + + return CommandResult{true, "", ""}; +} + +CommandResult NumbersRound::undoStep(uint32_t boardId) { + auto it = playerStates_.find(boardId); + if (it == playerStates_.end() || it->second.workspace.history.empty()) { + return CommandResult{false, "NOTHING_TO_UNDO", "No steps to undo"}; + } + NumberWorkspace& ws = it->second.workspace; + const CalculationStep& last = ws.history.back(); + + // Restore operands; remove result from wherever it ended up + ws.currentValue.reset(); + ws.availableValues.push_back(last.operandA); + ws.availableValues.push_back(last.operandB); + + // Remove the result value (it might be in currentValue or availableValues) + // Since we always remove it when adding a new step, just restore the pair. + ws.history.pop_back(); + if (!it->second.steps.empty()) it->second.steps.pop_back(); + + // Restore currentValue to the previous step's result if any + if (!ws.history.empty()) { + ws.currentValue = ws.history.back().result; + // Also remove that result from availableValues if it was banked there + } + + return CommandResult{true, "", ""}; +} + +CommandResult NumbersRound::completePresentation(uint32_t boardId) { + auto it = playerStates_.find(boardId); + if (it == playerStates_.end()) { + return CommandResult{false, "UNKNOWN_PLAYER", "Unknown player"}; + } + NumberPlayerState& ps = it->second; + if (ps.presentationComplete) { + return CommandResult{false, "ALREADY_COMPLETE", "Presentation already complete"}; + } + // Determine demonstrated value: currentValue if set, else last step result + int32_t demonstrated = 0; + if (ps.workspace.currentValue.has_value()) { + demonstrated = ps.workspace.currentValue.value(); + } else if (!ps.steps.empty()) { + demonstrated = ps.steps.back().result; + } + ps.demonstratedValue = demonstrated; + ps.presentationComplete = true; + ps.presentationValid = + (demonstrated == ps.claimedValue) || + (calculateScore(target_, static_cast(demonstrated)) > 0 && + ps.steps.empty()); // no steps = claimed 0 or just guessing + return CommandResult{true, "", ""}; +} + +const NumberPlayerState* NumbersRound::playerState(uint32_t boardId) const { + auto it = playerStates_.find(boardId); + return (it != playerStates_.end()) ? &it->second : nullptr; +} + +NumbersRoundProjection NumbersRound::projection() const { + NumbersRoundProjection p{}; + p.target = target_; + p.tileCount = static_cast(std::min(tiles_.size(), size_t{6})); + for (uint8_t i = 0; i < p.tileCount; ++i) p.tiles[i] = tiles_[i]; + return p; +} + +CommandResult NumbersRound::applyCommand(const CommandContext& ctx, + CommandType type, + const std::vector& payload) { + switch (type) { + case CommandType::NumSubmitClaim: { + if (payload.size() < 4) return {false, "BAD_PAYLOAD", ""}; + auto v = static_cast( + (static_cast(payload[0])) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24)); + return submitClaim(ctx.senderBoardId, v); + } + case CommandType::NumPresentStepRequest: { + if (payload.size() < 9) return {false, "BAD_PAYLOAD", ""}; + auto op = static_cast(payload[0]); + auto a = static_cast( + (static_cast(payload[1])) | + (static_cast(payload[2]) << 8) | + (static_cast(payload[3]) << 16) | + (static_cast(payload[4]) << 24)); + auto b = static_cast( + (static_cast(payload[5])) | + (static_cast(payload[6]) << 8) | + (static_cast(payload[7]) << 16) | + (static_cast(payload[8]) << 24)); + bool bank = (payload.size() > 9 && payload[9] != 0); + return applyStep(ctx.senderBoardId, op, a, b, bank); + } + case CommandType::NumPresentUndo: + case CommandType::NumPresentBankResult: + return undoStep(ctx.senderBoardId); + case CommandType::NumPresentComplete: + return completePresentation(ctx.senderBoardId); + default: + return {false, "WRONG_ROUND", "Command not valid for Numbers round"}; + } +} + +std::optional NumbersRound::tryFinalize( + const std::vector& playerBoardIds) { + // Finalize when both players have completed their presentations. + bool allDone = !playerBoardIds.empty(); + for (uint32_t id : playerBoardIds) { + auto it = playerStates_.find(id); + if (it == playerStates_.end() || !it->second.presentationComplete) { + allDone = false; + break; + } + } + if (!allDone) return std::nullopt; + + RoundResult res; + res.roundType = RoundType::Numbers; + + int32_t bestScore = -1; + for (uint32_t id : playerBoardIds) { + auto it = playerStates_.find(id); + if (it == playerStates_.end()) continue; + const auto& ps = it->second; + int32_t pts = (ps.presentationValid) + ? calculateScore(target_, static_cast(ps.demonstratedValue)) + : 0; + res.scoreAwards[id] = pts; + if (pts > bestScore) bestScore = pts; + } + + uint32_t winCount = 0; + for (auto& kv : res.scoreAwards) { + if (kv.second == bestScore && bestScore > 0) { + res.winnerBoardId = kv.first; + ++winCount; + } + } + if (winCount > 1) { res.isTie = true; res.winnerBoardId = 0; } + + return res; +} + +// -------------------------------------------------- +// LettersRound +// -------------------------------------------------- + +bool LettersRound::canDrawVowel() const { + size_t remaining = 9 - drawnLetters_.size(); + if (remaining == 0) return false; + if (vowelCount_ >= 5) return false; + // Must leave enough slots for minimum consonants (4) + size_t neededConsonants = (consonantCount_ < 4) ? (4 - consonantCount_) : 0; + if (remaining <= neededConsonants) return false; + return true; +} + +bool LettersRound::syncDrawnLetters(const std::string& letters) { + if (letters.size() > 9) return false; + size_t vowels = 0; + for (char letter : letters) { + const char upper = static_cast(std::toupper(static_cast(letter))); + if (upper < 'A' || upper > 'Z') return false; + if (upper == 'A' || upper == 'E' || upper == 'I' || + upper == 'O' || upper == 'U') { + ++vowels; + } + } + drawnLetters_ = letters; + vowelCount_ = vowels; + consonantCount_ = letters.size() - vowels; + return true; +} + +bool LettersRound::canDrawConsonant() const { + size_t remaining = 9 - drawnLetters_.size(); + if (remaining == 0) return false; + if (consonantCount_ >= 6) return false; + // Must leave enough slots for minimum vowels (3) + size_t neededVowels = (vowelCount_ < 3) ? (3 - vowelCount_) : 0; + if (remaining <= neededVowels) return false; + return true; +} + +bool LettersRound::drawVowel(IRandomSource& random) { + if (!canDrawVowel()) return false; + constexpr char vowels[] = {'A', 'E', 'I', 'O', 'U'}; + const char v = vowels[random.nextRange(0, 4)]; + drawnLetters_ += v; + ++vowelCount_; + return true; +} + +bool LettersRound::drawConsonant(IRandomSource& random) { + if (!canDrawConsonant()) return false; + constexpr char consonants[] = { + 'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', + 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z' + }; + const char c = consonants[random.nextRange(0, 20)]; + drawnLetters_ += c; + ++consonantCount_; + return true; +} + +bool LettersRound::isValidSubset(const std::string& word, + const std::string& availableLetters) { + std::map counts; + for (char c : availableLetters) { + counts[static_cast(std::toupper(static_cast(c)))]++; + } + for (char c : word) { + char upper = static_cast(std::toupper(static_cast(c))); + if (counts[upper] <= 0) return false; + counts[upper]--; + } + return true; +} + +CommandResult LettersRound::submitClaim(uint32_t boardId, uint8_t claimedLength) { + if (claimedLength == 0 || claimedLength > 9) { + return CommandResult{false, "INVALID_CLAIM", "Claimed length must be 1-9"}; + } + if (claims_.count(boardId)) { + return CommandResult{false, "ALREADY_CLAIMED", "Claim already submitted"}; + } + claims_[boardId] = claimedLength; + return CommandResult{true, "", ""}; +} + +bool LettersRound::bothClaimsSubmitted() const { + return claims_.size() >= 2; +} + +uint8_t LettersRound::claimFor(uint32_t boardId) const { + auto it = claims_.find(boardId); + return (it != claims_.end()) ? it->second : 0; +} + +CommandResult LettersRound::presentTileTap(uint32_t boardId, uint8_t tileIndex) { + if (tileIndex >= drawnLetters_.size()) { + return CommandResult{false, "BAD_INDEX", "Tile index out of range"}; + } + std::string& word = presentedWords_[boardId]; + // Count usage of each letter already in the word vs available + std::map used; + for (char c : word) used[c]++; + std::map pool; + for (char c : drawnLetters_) pool[c]++; + char tapped = drawnLetters_[tileIndex]; + if (used[tapped] >= pool[tapped]) { + return CommandResult{false, "TILE_USED", "That letter is already fully used"}; + } + word += tapped; + return CommandResult{true, "", ""}; +} + +CommandResult LettersRound::presentBackspace(uint32_t boardId) { + auto it = presentedWords_.find(boardId); + if (it == presentedWords_.end() || it->second.empty()) { + return CommandResult{false, "EMPTY", "No letters to remove"}; + } + it->second.pop_back(); + return CommandResult{true, "", ""}; +} + +CommandResult LettersRound::presentComplete(uint32_t boardId) { + const std::string& word = presentedWords_[boardId]; + uint8_t claimed = claimFor(boardId); + if (word.size() != claimed) { + return CommandResult{false, "LENGTH_MISMATCH", + "Presented word length does not match claim"}; + } + if (!isValidSubset(word, drawnLetters_)) { + return CommandResult{false, "INVALID_SUBSET", + "Word uses letters not in the drawn set"}; + } + return CommandResult{true, "", ""}; +} + +CommandResult LettersRound::verifyWord(uint32_t verifierBoardId, bool accepted) { + verifications_[verifierBoardId] = accepted; + return CommandResult{true, "", ""}; +} + +const std::string& LettersRound::presentedWordFor(uint32_t boardId) const { + static const std::string empty; + auto it = presentedWords_.find(boardId); + return (it != presentedWords_.end()) ? it->second : empty; +} + +bool LettersRound::verificationFor(uint32_t boardId) const { + auto it = verifications_.find(boardId); + return it != verifications_.end() && it->second; +} + +LettersRoundProjection LettersRound::projection() const { + LettersRoundProjection p{}; + p.letterCount = static_cast( + std::min(drawnLetters_.size(), size_t{9})); + for (uint8_t i = 0; i < p.letterCount; ++i) p.letters[i] = drawnLetters_[i]; + return p; +} + +CommandResult LettersRound::applyCommand(const CommandContext& ctx, + CommandType type, + const std::vector& payload) { + switch (type) { + case CommandType::LetPickVowel: { + // Host-only; random source not available here — pick is pre-resolved. + if (payload.empty()) return {false, "BAD_PAYLOAD", ""}; + char letter = static_cast(payload[0]); + drawnLetters_ += letter; + ++vowelCount_; + return {true, "", ""}; + } + case CommandType::LetPickConsonant: { + if (payload.empty()) return {false, "BAD_PAYLOAD", ""}; + char letter = static_cast(payload[0]); + drawnLetters_ += letter; + ++consonantCount_; + return {true, "", ""}; + } + case CommandType::LetSubmitClaim: { + if (payload.empty()) return {false, "BAD_PAYLOAD", ""}; + return submitClaim(ctx.senderBoardId, payload[0]); + } + case CommandType::LetPresentTileTap: { + if (payload.empty()) return {false, "BAD_PAYLOAD", ""}; + return presentTileTap(ctx.senderBoardId, payload[0]); + } + case CommandType::LetPresentBackspace: + return presentBackspace(ctx.senderBoardId); + case CommandType::LetPresentComplete: + return presentComplete(ctx.senderBoardId); + case CommandType::LetVerifyWord: { + if (payload.empty()) return {false, "BAD_PAYLOAD", ""}; + return verifyWord(ctx.senderBoardId, payload[0] != 0); + } + default: + return {false, "WRONG_ROUND", "Command not valid for Letters round"}; + } +} + +std::optional LettersRound::tryFinalize( + const std::vector& playerBoardIds) { + // Finalize when all verifications are in. + if (verifications_.size() < playerBoardIds.size()) return std::nullopt; + return evaluate(playerBoardIds); +} + +RoundResult LettersRound::evaluate(const std::vector& playerBoardIds) const { + RoundResult res; + res.roundType = RoundType::Letters; + + size_t maxLen = 0; + std::map validLengths; + + for (uint32_t id : playerBoardIds) { + auto wIt = presentedWords_.find(id); + auto vIt = verifications_.find(id); // verifier is the opponent + // A word is valid when: subset is correct, length matches claim, + // and the opponent accepted it. + bool accepted = (vIt != verifications_.end()) ? vIt->second : false; + if (wIt != presentedWords_.end() && + isValidSubset(wIt->second, drawnLetters_) && + accepted) { + size_t len = wIt->second.size(); + validLengths[id] = len; + if (len > maxLen) maxLen = len; + } + } + + if (maxLen == 0) return res; + + int32_t points = (maxLen == 9) ? 18 : static_cast(maxLen); + for (auto& kv : validLengths) { + if (kv.second == maxLen) res.scoreAwards[kv.first] = points; + } + + if (res.scoreAwards.size() == 1) { + res.winnerBoardId = res.scoreAwards.begin()->first; + } else if (res.scoreAwards.size() > 1) { + res.isTie = true; + } + return res; +} + +// -------------------------------------------------- +// ConundrumRound — built-in word table +// TODO: replace with content/conundrum/default_words.csv -> generated header +// -------------------------------------------------- + +const ConundrumEntry kConundrumTable[] = { + {.solution = "ADVENTURE", .scramble = "VENTUREAD", .hint = "An exciting or unusual experience"}, + {.solution = "BEAUTIFUL", .scramble = "TIFULBEAU", .hint = "Pleasant to look at"}, + {.solution = "BEGINNING", .scramble = "NINGBEGIN", .hint = "The start of something"}, + {.solution = "BLUEBERRY", .scramble = "BERRYBLUE", .hint = "A small blue edible fruit"}, + {.solution = "BLUEPRINT", .scramble = "TNPULEBRI", .hint = "A detailed plan or design"}, + {.solution = "BREAKFAST", .scramble = "FASTBREAK", .hint = "The first meal of the day"}, + {.solution = "CARPETING", .scramble = "TICNGPEAR", .hint = "Floor covering material"}, + {.solution = "CELEBRATE", .scramble = "BRATECELE", .hint = "To honor a special occasion"}, + {.solution = "CHAMPIONS", .scramble = "PIONSCHAM", .hint = "Winners of a competition"}, + {.solution = "CHOCOLATE", .scramble = "LATECHOCO", .hint = "Sweet treat made from cacao"}, + {.solution = "COMPUTERS", .scramble = "PUTERCOMS", .hint = "Electronic information machines"}, + {.solution = "COUNTDOWN", .scramble = "DTWNOCNUO", .hint = "A backward sequence before an event"}, + {.solution = "CREATURES", .scramble = "TURESCREA", .hint = "Living beings or animals"}, + {.solution = "CROSSWORD", .scramble = "WORDCROSS", .hint = "A word puzzle with intersecting answers"}, + {.solution = "DANGEROUS", .scramble = "GEROUSDAN", .hint = "Likely to cause harm"}, + {.solution = "DISCOVERY", .scramble = "COVERYDIS", .hint = "The finding of something new"}, + {.solution = "EDUCATION", .scramble = "CATIONEDU", .hint = "The process of learning"}, + {.solution = "ELEPHANTS", .scramble = "PHANTSELE", .hint = "Large animals with trunks"}, + {.solution = "EVERGREEN", .scramble = "GREENEVER", .hint = "A plant that stays green year-round"}, + {.solution = "FESTIVALS", .scramble = "TIVALSFES", .hint = "Large celebrations or events"}, + {.solution = "FIREPLACE", .scramble = "PLACEFIRE", .hint = "A hearth for a fire"}, + {.solution = "GARDENING", .scramble = "DENINGGAR", .hint = "The activity of growing plants"}, + {.solution = "GRENADIER", .scramble = "AIGNRDERE", .hint = "A type of soldier"}, + {.solution = "HAPPINESS", .scramble = "PINESSHAP", .hint = "A state of joy"}, + {.solution = "HEADLIGHT", .scramble = "LIGHTHEAD", .hint = "A forward-facing vehicle lamp"}, + {.solution = "IMPORTANT", .scramble = "TNAMIPORT", .hint = "Of great significance"}, + {.solution = "JELLYFISH", .scramble = "LYFISHJEL", .hint = "A drifting sea animal"}, + {.solution = "KEYBOARDS", .scramble = "BOARDKEYS", .hint = "Devices with keys for entering text"}, + {.solution = "KNOWLEDGE", .scramble = "LEDGEKNOW", .hint = "Information and understanding"}, + {.solution = "LANDSCAPE", .scramble = "SCAPELAND", .hint = "The visible features of an area"}, + {.solution = "LIGHTNING", .scramble = "NINGLIGHT", .hint = "A flash of electricity in a storm"}, + {.solution = "MAGNETISM", .scramble = "NETISMMAG", .hint = "A force that attracts iron"}, + {.solution = "MOUNTAINS", .scramble = "TAINSMOUN", .hint = "Very high natural elevations"}, + {.solution = "MUSICALLY", .scramble = "CALLYMUSI", .hint = "In a way related to music"}, + {.solution = "NOTEBOOKS", .scramble = "BOOKSNOTE", .hint = "Books used for writing notes"}, + {.solution = "ORCHESTRA", .scramble = "ESTRAORCH", .hint = "A large group of instrumentalists"}, + {.solution = "PAINTINGS", .scramble = "TINGSPAIN", .hint = "Pictures made with paint"}, + {.solution = "PAPERBACK", .scramble = "BACKPAPER", .hint = "A book with a flexible cover"}, + {.solution = "PETROLEUM", .scramble = "MOUERTPLE", .hint = "Crude oil and its derivatives"}, + {.solution = "PINEAPPLE", .scramble = "APPLEPINE", .hint = "A tropical fruit with spiky skin"}, + {.solution = "POLITICAL", .scramble = "TICALPOLI", .hint = "Relating to government"}, + {.solution = "RECTANGLE", .scramble = "CTRELAGNE", .hint = "A four-sided flat shape"}, + {.solution = "SOMETHING", .scramble = "THINGSOME", .hint = "An unspecified object or matter"}, + {.solution = "SPECTATOR", .scramble = "POSTCATER", .hint = "Someone watching an event"}, + {.solution = "STARTLING", .scramble = "GTLNRSAIT", .hint = "Surprising or astonishing"}, + {.solution = "SUNFLOWER", .scramble = "FLOWERSUN", .hint = "A tall plant with a yellow bloom"}, + {.solution = "TELESCOPE", .scramble = "SCOPETELE", .hint = "An instrument for viewing distant objects"}, + {.solution = "UNDERLINE", .scramble = "LINEUNDER", .hint = "A line drawn beneath text"}, + {.solution = "WONDERFUL", .scramble = "DERFULWON", .hint = "Extremely good"}, + {.solution = "WORKPLACE", .scramble = "PLACEWORK", .hint = "A location where people work"}, +}; +const std::size_t kConundrumTableSize = + std::size(kConundrumTable); +static_assert(std::size(kConundrumTable) - 1 <= + std::numeric_limits::max(), + "Conundrum table indices must fit IRandomSource::nextRange"); + +ConundrumRound::ConundrumRound(std::string solution, + std::string scramble, + std::string hint) + : solution_(std::move(solution)), scramble_(std::move(scramble)), hint_(std::move(hint)) {} + +ConundrumRound ConundrumRound::createRandom(IRandomSource& random) { + if (kConundrumTableSize == 0) { + return ConundrumRound{"", "", ""}; + } + size_t idx = random.nextRange(0, static_cast(kConundrumTableSize - 1)); + const auto& entry = kConundrumTable[idx]; + return {entry.solution, entry.scramble, entry.hint}; +} + +CommandResult ConundrumRound::submitAttempt(uint32_t boardId, + const std::string& attempt) { + if (solved_) { + return CommandResult{false, "ALREADY_SOLVED", "Conundrum already solved"}; + } + if (expired_) { + return CommandResult{false, "DEADLINE_ELAPSED", "Buzz-window has expired"}; + } + std::string clean = attempt; + std::string correct = solution_; + for (auto& c : clean) c = static_cast(std::toupper(static_cast(c))); + for (auto& c : correct) c = static_cast(std::toupper(static_cast(c))); + + if (clean == correct) { + solved_ = true; + winnerBoardId_ = boardId; + return CommandResult{true, "", ""}; + } + // Wrong answer — caller handles local reshuffle; no state mutation. + return CommandResult{false, "INCORRECT", "Incorrect answer — try again"}; +} + +ConundrumRoundProjection ConundrumRound::projection() const { + ConundrumRoundProjection p{}; + std::strncpy(p.solution, solution_.c_str(), sizeof(p.solution) - 1); + std::strncpy(p.scramble, scramble_.c_str(), sizeof(p.scramble) - 1); + std::strncpy(p.hint, hint_.c_str(), sizeof(p.hint) - 1); + p.solved = solved_; + p.expired = expired_; + p.winnerBoardId = winnerBoardId_; + return p; +} + +CommandResult ConundrumRound::applyCommand(const CommandContext& ctx, + CommandType type, + const std::vector& payload) { + if (type != CommandType::ConSubmitAttempt) { + return {false, "WRONG_ROUND", "Command not valid for Conundrum round"}; + } + std::string attempt(payload.begin(), payload.end()); + return submitAttempt(ctx.senderBoardId, attempt); +} + +void ConundrumRound::markExpired() { + expired_ = true; +} + +std::optional ConundrumRound::tryFinalize( + const std::vector& /*playerBoardIds*/) { + if (!solved_ && !expired_) return std::nullopt; + if (expired_ && !solved_) { + RoundResult res; + res.roundType = RoundType::Conundrum; + res.isTie = true; + return res; + } + + RoundResult res; + res.roundType = RoundType::Conundrum; + res.winnerBoardId = winnerBoardId_; + res.scoreAwards[winnerBoardId_] = 10; + return res; +} + +// -------------------------------------------------- +// CountdownMatchEngine +// -------------------------------------------------- + +CountdownMatchEngine::CountdownMatchEngine(uint32_t hostBoardId, + uint32_t guestBoardId, + uint32_t gameId) + : gameId_(gameId), + hostBoardId_(hostBoardId), + guestBoardId_(guestBoardId), + chooserBoardId_(hostBoardId), + hostTerm_(1) { + hostPlayer_.boardId = hostBoardId; + hostPlayer_.isHost = true; + guestPlayer_.boardId = guestBoardId; + phase_ = MatchPhase::Setup; +} + +void CountdownMatchEngine::startNextRound(RoundType type, IRandomSource& random, + uint8_t largeCount) { + activeRound_.reset(); + activeRoundType_ = type; + switch (type) { + case RoundType::Numbers: + activeRound_ = std::make_unique( + NumbersRound::createRandom(random, largeCount)); + break; + case RoundType::Letters: + activeRound_ = std::make_unique(); + break; + case RoundType::Conundrum: + activeRound_ = std::make_unique( + ConundrumRound::createRandom(random)); + break; + } + ++roundNumber_; + phase_ = MatchPhase::InRound; +} + +NumbersRound* CountdownMatchEngine::numbersRound() { + return (activeRound_ && activeRoundType_ == RoundType::Numbers) + ? static_cast(activeRound_.get()) // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + : nullptr; +} +const NumbersRound* CountdownMatchEngine::numbersRound() const { + return (activeRound_ && activeRoundType_ == RoundType::Numbers) + ? static_cast(activeRound_.get()) // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + : nullptr; +} +LettersRound* CountdownMatchEngine::lettersRound() { + return (activeRound_ && activeRoundType_ == RoundType::Letters) + ? static_cast(activeRound_.get()) // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + : nullptr; +} +const LettersRound* CountdownMatchEngine::lettersRound() const { + return (activeRound_ && activeRoundType_ == RoundType::Letters) + ? static_cast(activeRound_.get()) // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + : nullptr; +} +ConundrumRound* CountdownMatchEngine::conundrumRound() { + return (activeRound_ && activeRoundType_ == RoundType::Conundrum) + ? static_cast(activeRound_.get()) // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + : nullptr; +} +const ConundrumRound* CountdownMatchEngine::conundrumRound() const { + return (activeRound_ && activeRoundType_ == RoundType::Conundrum) + ? static_cast(activeRound_.get()) // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + : nullptr; +} + +NumbersRoundProjection CountdownMatchEngine::numbersProjection() const { + const NumbersRound* r = numbersRound(); + return r ? r->projection() : NumbersRoundProjection{}; +} +LettersRoundProjection CountdownMatchEngine::lettersProjection() const { + const LettersRound* r = lettersRound(); + return r ? r->projection() : LettersRoundProjection{}; +} +ConundrumRoundProjection CountdownMatchEngine::conundrumProjection() const { + const ConundrumRound* r = conundrumRound(); + return r ? r->projection() : ConundrumRoundProjection{}; +} + +void CountdownMatchEngine::recordRoundResult(const RoundResult& result) { + // Apply score awards. + for (const auto& kv : result.scoreAwards) { + if (kv.first == hostPlayer_.boardId) { + hostPlayer_.score += kv.second; + } else if (kv.first == guestPlayer_.boardId) { + guestPlayer_.score += kv.second; + } + } + + phase_ = MatchPhase::BetweenRounds; + activeRound_.reset(); + + // Update chooser (round winner picks next round). + // No winner (tie or timeout) retains the previous chooser. + if (!result.isTie && result.winnerBoardId != 0) { + chooserBoardId_ = result.winnerBoardId; + } + + // ADR-005 Leader-based host migration: score leader becomes host. + // Ties retain the current host; hostTerm increments on transfer. + if (guestPlayer_.score > hostPlayer_.score) { + ++hostTerm_; + std::swap(hostBoardId_, guestBoardId_); + hostPlayer_.isHost = false; + guestPlayer_.isHost = true; + std::swap(hostPlayer_, guestPlayer_); + } +} + +} // namespace flip7::countdown diff --git a/core/src/puzzle.cpp b/core/src/puzzle.cpp index 2c31e48..c4cdc0d 100644 --- a/core/src/puzzle.cpp +++ b/core/src/puzzle.cpp @@ -41,7 +41,7 @@ PuzzleState makeInitialPuzzle(uint32_t firstTurnBoardId, uint32_t gameId, const } // Check if two positions are adjacent -inline bool arePositionsAdjacent(const PuzzleState& state, uint8_t first, uint8_t second) { +bool arePositionsAdjacent(const PuzzleState& state, uint8_t first, uint8_t second) { const uint8_t count = puzzleTileCount(state); if (first >= count || second >= count || state.columns == 0) return false; @@ -57,7 +57,7 @@ inline bool arePositionsAdjacent(const PuzzleState& state, uint8_t first, uint8_ } // Deterministic scramble using game ID as seed -inline PuzzleState makeScrambledPuzzle(uint32_t firstTurnBoardId, uint32_t gameId, const PuzzleSpec& spec) { +PuzzleState makeScrambledPuzzle(uint32_t firstTurnBoardId, uint32_t gameId, const PuzzleSpec& spec) { PuzzleState state = makeInitialPuzzle(firstTurnBoardId, gameId, spec); if (!isSupportedPuzzleSpec(spec)) return state; @@ -106,7 +106,7 @@ inline PuzzleState makeScrambledPuzzle(uint32_t firstTurnBoardId, uint32_t gameI } // Check if puzzle arrangement is solvable -inline bool isSolvablePuzzleArrangement(const PuzzleState& state) { +bool isSolvablePuzzleArrangement(const PuzzleState& state) { const uint8_t count = puzzleTileCount(state); uint16_t inversions = 0; @@ -138,7 +138,7 @@ inline bool isSolvablePuzzleArrangement(const PuzzleState& state) { } // Full validation -inline bool isValidPuzzle(const PuzzleState& state) { +bool isValidPuzzle(const PuzzleState& state) { const PuzzleSpec spec = {state.columns, state.rows, state.theme}; if (!isSupportedPuzzleSpec(spec) || (state.phase != PuzzlePhase::Playing && state.phase != PuzzlePhase::Exited) || @@ -159,7 +159,7 @@ inline bool isValidPuzzle(const PuzzleState& state) { return isSolvablePuzzleArrangement(state); } -inline uint8_t findBlank(const PuzzleState& state) { +uint8_t findBlank(const PuzzleState& state) { const uint8_t count = puzzleTileCount(state); for (uint8_t i = 0; i < count; ++i) { if (state.tiles[i] == 0) return i; @@ -167,12 +167,12 @@ inline uint8_t findBlank(const PuzzleState& state) { return count; } -inline bool isTileCorrect(const PuzzleState& state, uint8_t position) { +bool isTileCorrect(const PuzzleState& state, uint8_t position) { const uint8_t count = puzzleTileCount(state); return position + 1 < count && state.tiles[position] == position + 1; } -inline bool isPuzzleSolved(const PuzzleState& state) { +bool isPuzzleSolved(const PuzzleState& state) { if (!isValidPuzzle(state) || state.phase != PuzzlePhase::Playing) return false; const uint8_t count = puzzleTileCount(state); for (uint8_t pos = 0; pos + 1 < count; ++pos) { @@ -181,7 +181,7 @@ inline bool isPuzzleSolved(const PuzzleState& state) { return state.tiles[count - 1] == 0; } -inline bool isSamePuzzle(const PuzzleState& first, const PuzzleState& second) { +bool isSamePuzzle(const PuzzleState& first, const PuzzleState& second) { if (first.columns != second.columns || first.rows != second.rows || first.theme != second.theme || first.phase != second.phase || first.gameId != second.gameId || first.revision != second.revision || @@ -194,7 +194,7 @@ inline bool isSamePuzzle(const PuzzleState& first, const PuzzleState& second) { return true; } -inline PuzzleVersionOrder comparePuzzleVersion(const PuzzleState& current, const PuzzleState& candidate) { +PuzzleVersionOrder comparePuzzleVersion(const PuzzleState& current, const PuzzleState& candidate) { if (candidate.gameId != current.gameId) { return candidate.gameId > current.gameId ? PuzzleVersionOrder::Newer : PuzzleVersionOrder::Older; } @@ -204,7 +204,7 @@ inline PuzzleVersionOrder comparePuzzleVersion(const PuzzleState& current, const return PuzzleVersionOrder::Same; } -inline uint32_t puzzleStateDigest(const PuzzleState& state) { +uint32_t puzzleStateDigest(const PuzzleState& state) { uint32_t digest = 2166136261u; auto addByte = [&digest](uint8_t value) { digest ^= value; @@ -224,11 +224,11 @@ inline uint32_t puzzleStateDigest(const PuzzleState& state) { return digest; } -inline bool isDeliverySuperseded(const PuzzleState& pending, const PuzzleState& observed) { +bool isDeliverySuperseded(const PuzzleState& pending, const PuzzleState& observed) { return pending.gameId == observed.gameId && pending.revision < observed.revision; } -inline ReconciliationAction decideReconciliation( +ReconciliationAction decideReconciliation( const PuzzleState& local, uint32_t remoteGameId, uint32_t remoteRevision, uint32_t remoteDigest, bool localIsAuthority) { if (remoteGameId < local.gameId || @@ -246,7 +246,7 @@ inline ReconciliationAction decideReconciliation( : ReconciliationAction::RequestFullState; } -inline bool tryPuzzleMove(PuzzleState& state, uint8_t position, +bool tryPuzzleMove(PuzzleState& state, uint8_t position, uint32_t actorBoardId, uint32_t nextTurnBoardId) { if (!isValidPuzzle(state) || state.phase != PuzzlePhase::Playing || state.turnBoardId != actorBoardId || @@ -264,7 +264,7 @@ inline bool tryPuzzleMove(PuzzleState& state, uint8_t position, return true; } -inline bool exitPuzzle(PuzzleState& state, uint32_t actorBoardId, uint32_t otherBoardId) { +bool exitPuzzle(PuzzleState& state, uint32_t actorBoardId, uint32_t otherBoardId) { if (!isValidPuzzle(state) || state.phase != PuzzlePhase::Playing || actorBoardId == 0 || otherBoardId == 0 || actorBoardId == otherBoardId || state.revision == UINT32_MAX || @@ -276,7 +276,7 @@ inline bool exitPuzzle(PuzzleState& state, uint32_t actorBoardId, uint32_t other return true; } -inline bool applyPuzzleExitSignal(PuzzleState& current, const PuzzleState& candidate, +bool applyPuzzleExitSignal(PuzzleState& current, const PuzzleState& candidate, uint32_t senderBoardId, uint32_t recipientBoardId) { if (!isValidPuzzle(current) || !isValidPuzzle(candidate) || candidate.phase != PuzzlePhase::Exited || @@ -306,7 +306,7 @@ inline bool applyPuzzleExitSignal(PuzzleState& current, const PuzzleState& candi return true; } -inline bool isValidRemoteTransition(const PuzzleState& current, const PuzzleState& incoming, +bool isValidRemoteTransition(const PuzzleState& current, const PuzzleState& incoming, uint32_t senderBoardId, uint32_t recipientBoardId) { if (!isValidPuzzle(current) || !isValidPuzzle(incoming) || current.phase != PuzzlePhase::Playing || incoming.phase != PuzzlePhase::Playing || @@ -334,7 +334,7 @@ inline bool isValidRemoteTransition(const PuzzleState& current, const PuzzleStat return true; } -inline bool isValidPuzzleForParticipants(const PuzzleState& state, +bool isValidPuzzleForParticipants(const PuzzleState& state, uint32_t firstBoardId, uint32_t secondBoardId) { if (!isValidPuzzle(state) || firstBoardId == 0 || secondBoardId == 0 || firstBoardId == secondBoardId) return false; @@ -347,7 +347,7 @@ inline bool isValidPuzzleForParticipants(const PuzzleState& state, return state.turnBoardId == expectedTurn; } -inline bool shouldAdoptFullState(const PuzzleState& current, const PuzzleState& incoming, +bool shouldAdoptFullState(const PuzzleState& current, const PuzzleState& incoming, uint32_t senderBoardId, uint32_t recipientBoardId) { if (!isValidPuzzleForParticipants(incoming, senderBoardId, recipientBoardId) || incoming.phase != PuzzlePhase::Playing || diff --git a/core/src/reconciliation.cpp b/core/src/reconciliation.cpp new file mode 100644 index 0000000..4111f48 --- /dev/null +++ b/core/src/reconciliation.cpp @@ -0,0 +1,13 @@ +/** + * Flip7 Core - Reconciliation Logic + * State merge/sync decisions + */ + +#include "flip7/protocol.hpp" + +namespace flip7 { + +// This file provides the implementation for functions declared in protocol.hpp +// The actual implementations are in the inline functions in protocol.hpp and in puzzle.cpp/mastermind.cpp + +} // namespace flip7 \ No newline at end of file diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt new file mode 100644 index 0000000..92b6a63 --- /dev/null +++ b/core/tests/CMakeLists.txt @@ -0,0 +1,27 @@ +# GoogleTest for flip7core +# Build: cmake -DBUILD_TESTS=ON .. && make flip7core_tests + +cmake_minimum_required(VERSION 3.16) +project(flip7core_tests LANGUAGES CXX) + +find_package(GTest REQUIRED) + +# Build the core library first (defines flip7core target) +set(BUILD_TESTS OFF) +add_subdirectory(..) + +# Test executable - only include test files that actually exist +add_executable(flip7core_tests + protocol_test.cpp + serialization_test.cpp + reconciliation_test.cpp + countdown_test.cpp +) + +target_link_libraries(flip7core_tests PRIVATE + flip7core + GTest::GTest + GTest::Main +) + +add_test(NAME flip7core_tests COMMAND flip7core_tests) \ No newline at end of file diff --git a/core/tests/countdown_test.cpp b/core/tests/countdown_test.cpp new file mode 100644 index 0000000..a8de72e --- /dev/null +++ b/core/tests/countdown_test.cpp @@ -0,0 +1,390 @@ +/** + * Countdown Engine Unit Tests + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "flip7/conundrum_table.hpp" +#include "flip7/countdown_engine.hpp" + +using namespace flip7::countdown; + +namespace { + +std::array letterHistogram(std::string_view word) { + std::array counts{}; + for (char c : word) { + EXPECT_GE(c, 'A'); + EXPECT_LE(c, 'Z'); + ++counts[c - 'A']; + } + return counts; +} + +} // namespace + +TEST(ConundrumTable, AllEntriesAreValid) { + std::set uniqueSolutions; + + for (std::size_t i = 0; i < kConundrumTableSize; ++i) { + const auto& entry = kConundrumTable[i]; + SCOPED_TRACE(::testing::Message() << "Conundrum entry " << i); + + ASSERT_NE(entry.solution, nullptr); + ASSERT_NE(entry.scramble, nullptr); + ASSERT_NE(entry.hint, nullptr); + SCOPED_TRACE(entry.solution); + + ASSERT_EQ(std::strlen(entry.solution), 9u) + << "Solution must be exactly 9 letters."; + ASSERT_FALSE(std::string_view(entry.solution).empty()); + + for (char c : std::string_view(entry.solution)) { + EXPECT_TRUE(std::isupper(static_cast(c))) + << "Solution contains lowercase characters."; + } + + ASSERT_EQ(std::strlen(entry.scramble), 9u) + << "Scramble must be exactly 9 letters."; + EXPECT_NE(std::string(entry.solution), std::string(entry.scramble)) + << "Scramble is identical to solution."; + EXPECT_EQ(letterHistogram(entry.solution), letterHistogram(entry.scramble)) + << "Scramble is not an exact anagram."; + + EXPECT_FALSE(std::string_view(entry.hint).empty()) + << "Hint may not be empty."; + EXPECT_TRUE(uniqueSolutions.insert(entry.solution).second) + << "Duplicate solution found."; + } +} + +// --------------------------------------------------------------------------- +// makeRoundRandom — determinism +// --------------------------------------------------------------------------- + +TEST(CountdownTest, MakeRoundRandomIsDeterministic) { + auto r1 = makeRoundRandom(0xABCD1234u, 3u); + auto r2 = makeRoundRandom(0xABCD1234u, 3u); + EXPECT_EQ(r1.next(), r2.next()); + EXPECT_EQ(r1.next(), r2.next()); +} + +TEST(CountdownTest, MakeRoundRandomDiffersAcrossRounds) { + auto r1 = makeRoundRandom(42u, 1u); + auto r2 = makeRoundRandom(42u, 2u); + EXPECT_NE(r1.next(), r2.next()); +} + +// --------------------------------------------------------------------------- +// NumbersRound — generation, scoring, claims, steps +// --------------------------------------------------------------------------- + +TEST(CountdownTest, NumbersRoundGenerationAndScoring) { + SeededRandomSource random(42); + NumbersRound round = NumbersRound::createRandom(random, 2); + + EXPECT_GE(round.target(), 101u); + EXPECT_LE(round.target(), 999u); + EXPECT_EQ(round.tiles().size(), 6u); + + EXPECT_EQ(NumbersRound::calculateScore(500, 500), 10); + EXPECT_EQ(NumbersRound::calculateScore(500, 503), 7); + EXPECT_EQ(NumbersRound::calculateScore(500, 508), 5); + EXPECT_EQ(NumbersRound::calculateScore(500, 515), 0); +} + +TEST(CountdownTest, NumbersRoundClaims) { + SeededRandomSource random(1); + NumbersRound round = NumbersRound::createRandom(random, 0); + + EXPECT_FALSE(round.bothClaimsSubmitted()); + + auto r1 = round.submitClaim(0x11, 480); + EXPECT_TRUE(r1.accepted); + EXPECT_FALSE(round.bothClaimsSubmitted()); + + auto r2 = round.submitClaim(0x22, 495); + EXPECT_TRUE(r2.accepted); + EXPECT_TRUE(round.bothClaimsSubmitted()); + + // Duplicate claim rejected + auto r3 = round.submitClaim(0x11, 490); + EXPECT_FALSE(r3.accepted); + EXPECT_EQ(r3.errorCode, "ALREADY_CLAIMED"); + + EXPECT_EQ(round.claimFor(0x11), 480); + EXPECT_EQ(round.claimFor(0x22), 495); +} + +TEST(CountdownTest, NumbersRoundArithmeticStep) { + // Simple round: target 100, tiles [25, 50, 4, 2, 1, 1] + NumbersRound round(100, {25, 50, 4, 2, 1, 1}); + round.submitClaim(0x01, 100); + + // 25 + 50 = 75 + auto r1 = round.applyStep(0x01, ArithmeticOp::Add, 25, 50); + EXPECT_TRUE(r1.accepted); + + // 75 + 25 would fail — 25 is consumed + auto r2 = round.applyStep(0x01, ArithmeticOp::Add, 75, 25); + EXPECT_FALSE(r2.accepted); // 25 no longer available + + // 75 + 4 = 79 + auto r3 = round.applyStep(0x01, ArithmeticOp::Add, 75, 4); + EXPECT_TRUE(r3.accepted); + + // Undo + auto r4 = round.undoStep(0x01); + EXPECT_TRUE(r4.accepted); +} + +TEST(CountdownTest, NumbersRoundDivisionValidation) { + NumbersRound round(300, {100, 25, 4, 3, 2, 1}); + round.submitClaim(0x01, 300); + + // Valid integer division: 100 / 4 = 25 + auto r1 = round.applyStep(0x01, ArithmeticOp::Divide, 100, 4); + EXPECT_TRUE(r1.accepted); + + // Non-integer division rejected: 25 / 4 + auto claim2 = round.submitClaim(0x02, 100); + EXPECT_TRUE(claim2.accepted); + auto r2 = round.applyStep(0x02, ArithmeticOp::Divide, 25, 4); + EXPECT_FALSE(r2.accepted); + EXPECT_EQ(r2.errorCode, "INVALID_DIVISION"); +} + +// --------------------------------------------------------------------------- +// LettersRound — picking constraints, claims, tile presentation +// --------------------------------------------------------------------------- + +TEST(CountdownTest, LettersRoundPickingConstraints) { + SeededRandomSource random(1234); + LettersRound round; + + // Draw 4 vowels; 5th should be blocked (max 5 but we need room for 4 consonants) + for (int i = 0; i < 4; ++i) { + EXPECT_TRUE(round.canDrawVowel()); + EXPECT_TRUE(round.drawVowel(random)); + } + + // At 4 vowels, 0 consonants, 5 remaining → vowel still allowed (max is 5) + EXPECT_TRUE(round.canDrawVowel()); + + // Draw 3 consonants + for (int i = 0; i < 3; ++i) { + EXPECT_TRUE(round.canDrawConsonant()); + EXPECT_TRUE(round.drawConsonant(random)); + } + + // Now 4 vowels, 3 consonants, 2 remaining — must have at least 1 more consonant + // Vowel still ok (4 < 5 and remaining 2 > neededConsonants 1) + EXPECT_TRUE(round.canDrawVowel()); + + // Draw 5th vowel + EXPECT_TRUE(round.drawVowel(random)); + // Now 5 vowels — vowel button disabled + EXPECT_FALSE(round.canDrawVowel()); + + // Draw last consonant + EXPECT_TRUE(round.drawConsonant(random)); + EXPECT_EQ(round.letterCount(), 9u); + EXPECT_TRUE(round.isSelectionComplete()); +} + +TEST(CountdownTest, LettersRoundValidSubset) { + SeededRandomSource random(1234); + LettersRound round; + for (int i = 0; i < 4; ++i) round.drawVowel(random); + for (int i = 0; i < 5; ++i) round.drawConsonant(random); + + EXPECT_EQ(round.letterCount(), 9u); + EXPECT_TRUE(LettersRound::isValidSubset("", round.drawnLetters())); + EXPECT_FALSE(LettersRound::isValidSubset("ZZZZZZZZZZ", round.drawnLetters())); +} + +TEST(CountdownTest, LettersRoundClaimsAndPresentation) { + LettersRound round; + // Manually set letters for determinism + for (char c: std::string("AEIORCNTS")) { + // Use applyCommand to add each letter + CommandContext ctx{0x10, 0}; + std::vector payload = {static_cast(c)}; + round.applyCommand(ctx, CommandType::LetPickVowel, payload); + } + + // Claim length 5 + auto r1 = round.submitClaim(0x101, 5); + EXPECT_TRUE(r1.accepted); + + // Duplicate claim rejected + EXPECT_FALSE(round.submitClaim(0x101, 6).accepted); + + // Length must be 1-9 + EXPECT_FALSE(round.submitClaim(0x102, 0).accepted); + EXPECT_TRUE(round.submitClaim(0x102, 7).accepted); + EXPECT_TRUE(round.bothClaimsSubmitted()); +} + +// --------------------------------------------------------------------------- +// ConundrumRound — simultaneous attempts (no buzz) +// --------------------------------------------------------------------------- + +TEST(CountdownTest, ConundrumRoundSimultaneousAttempts) { + ConundrumRound round("BLUEPRINT", "TNPULEBRI", "A detailed plan or design"); + + EXPECT_EQ(round.solution(), "BLUEPRINT"); + EXPECT_EQ(round.hint(), "A detailed plan or design"); + EXPECT_FALSE(round.isSolved()); + + // Player 1 incorrect — no lockout + auto r1 = round.submitAttempt(0x201, "WRONGWORD"); + EXPECT_FALSE(r1.accepted); + EXPECT_FALSE(round.isSolved()); + + // Player 2 can immediately attempt — simultaneous + auto r2 = round.submitAttempt(0x202, "BLUEPRINT"); + EXPECT_TRUE(r2.accepted); + EXPECT_TRUE(round.isSolved()); + EXPECT_EQ(round.winnerBoardId(), 0x202u); +} + +TEST(CountdownTest, ConundrumRoundAlreadySolved) { + ConundrumRound round("BLUEPRINT", "TNPULEBRI", "A hint"); + round.submitAttempt(0x201, "BLUEPRINT"); + + // Further attempts rejected once solved + auto r = round.submitAttempt(0x202, "BLUEPRINT"); + EXPECT_FALSE(r.accepted); + EXPECT_EQ(r.errorCode, "ALREADY_SOLVED"); +} + +TEST(CountdownTest, ConundrumRoundCreateRandom) { + auto random = makeRoundRandom(999u, 5u); + ConundrumRound round = ConundrumRound::createRandom(random); + + EXPECT_FALSE(round.solution().empty()); + EXPECT_FALSE(round.scramble().empty()); + EXPECT_FALSE(round.hint().empty()); + EXPECT_EQ(round.solution().size(), 9u); +} + +// --------------------------------------------------------------------------- +// MatchEngine — startNextRound creates active round; dual-role recordRoundResult +// --------------------------------------------------------------------------- + +TEST(CountdownTest, MatchEngineStartNextRoundCreatesRound) { + CountdownMatchEngine engine(0xA1, 0xB2, 999); + + EXPECT_EQ(engine.activeRound(), nullptr); + + auto random = makeRoundRandom(999u, 1u); + engine.startNextRound(RoundType::Numbers, random, 2); + + EXPECT_NE(engine.activeRound(), nullptr); + EXPECT_EQ(engine.activeRound()->roundType(), RoundType::Numbers); + EXPECT_NE(engine.numbersRound(), nullptr); + EXPECT_EQ(engine.lettersRound(), nullptr); + EXPECT_EQ(engine.conundrumRound(), nullptr); +} + +TEST(CountdownTest, MatchEngineLeaderHostMigrationAndChooserUpdate) { + uint32_t hostId = 0xA1; + uint32_t guestId = 0xB2; + uint32_t gameId = 999; + + CountdownMatchEngine engine(hostId, guestId, gameId); + EXPECT_EQ(engine.hostBoardId(), hostId); + EXPECT_EQ(engine.guestBoardId(), guestId); + EXPECT_EQ(engine.chooserBoardId(), hostId); // host chooses first + EXPECT_EQ(engine.hostTerm(), 1u); + + auto random = makeRoundRandom(gameId, 1u); + engine.startNextRound(RoundType::Numbers, random, 2); + + // Guest wins the round — becomes chooser AND takes score lead → becomes host + RoundResult rr; + rr.roundType = RoundType::Numbers; + rr.winnerBoardId = guestId; + rr.scoreAwards[guestId] = 10; + + engine.recordRoundResult(rr); + + // ADR-005: guest took score lead → guest becomes new host + EXPECT_EQ(engine.hostBoardId(), guestId); + EXPECT_EQ(engine.guestBoardId(), hostId); + EXPECT_EQ(engine.hostTerm(), 2u); // incremented on transfer + + // chooser = round winner = guestId + EXPECT_EQ(engine.chooserBoardId(), guestId); +} + +TEST(CountdownTest, MatchEngineChooserRetainedOnTie) { + CountdownMatchEngine engine(0xA1, 0xB2, 1); + auto random = makeRoundRandom(1u, 1u); + engine.startNextRound(RoundType::Letters, random); + + // Tie round — no winner + RoundResult rr; + rr.roundType = RoundType::Letters; + rr.isTie = true; + rr.scoreAwards[0xA1] = 5; + rr.scoreAwards[0xB2] = 5; + + uint32_t prevChooser = engine.chooserBoardId(); + engine.recordRoundResult(rr); + + // Tie: previous chooser retained; no host migration (scores equal) + EXPECT_EQ(engine.chooserBoardId(), prevChooser); + EXPECT_EQ(engine.hostTerm(), 1u); // no transfer +} + +// --------------------------------------------------------------------------- +// IRound interface — applyCommand dispatch +// --------------------------------------------------------------------------- + +TEST(CountdownTest, IRoundNumbersApplyCommandClaim) { + auto random = makeRoundRandom(10u, 1u); + NumbersRound round = NumbersRound::createRandom(random, 2); + + CommandContext ctx{0x01, 0}; + // Encode claimed value 350 as little-endian uint32 + std::vector payload = {0x5E, 0x01, 0x00, 0x00}; // 0x15E = 350 + auto result = round.applyCommand(ctx, CommandType::NumSubmitClaim, payload); + EXPECT_TRUE(result.accepted); + EXPECT_EQ(round.claimFor(0x01), 350); +} + +TEST(CountdownTest, IRoundConundrumApplyCommand) { + ConundrumRound round("STARTLING", "GTLNRSAIT", "Surprising"); + CommandContext ctx{0x10, 0}; + std::vector attempt{'S', 'T', 'A', 'R', 'T', 'L', 'I', 'N', 'G'}; + auto result = round.applyCommand(ctx, CommandType::ConSubmitAttempt, attempt); + EXPECT_TRUE(result.accepted); + EXPECT_TRUE(round.isSolved()); +} + +// --------------------------------------------------------------------------- +// FixedVector +// --------------------------------------------------------------------------- + +TEST(CountdownTest, FixedVectorCapacity) { + FixedVector < int, 3 > v; + EXPECT_TRUE(v.empty()); + EXPECT_TRUE(v.push_back(1)); + EXPECT_TRUE(v.push_back(2)); + EXPECT_TRUE(v.push_back(3)); + EXPECT_TRUE(v.full()); + EXPECT_FALSE(v.push_back(4)); // over capacity + EXPECT_EQ(v.size(), 3u); + EXPECT_EQ(v[0], 1); + EXPECT_TRUE(v.pop_back()); + EXPECT_EQ(v.size(), 2u); + EXPECT_FALSE(v.full()); +} diff --git a/core/tests/protocol_test.cpp b/core/tests/protocol_test.cpp new file mode 100644 index 0000000..351ab43 --- /dev/null +++ b/core/tests/protocol_test.cpp @@ -0,0 +1,74 @@ +/** + * Protocol Tests - Core protocol definitions and utilities + */ + +#include +#include "flip7/protocol.hpp" + +using namespace flip7; + +TEST(ProtocolTest, ProtocolConstants) { + EXPECT_EQ(kProtocolMagic, 0x46374359u); + EXPECT_EQ(kProtocolVersion, 7); + EXPECT_EQ(kPacketHeaderSize, 20); + EXPECT_EQ(kHeartbeatPacketSize, 24); + EXPECT_EQ(kPuzzleStatePacketSize, 68); + EXPECT_EQ(kPuzzleAckPacketSize, 40); + EXPECT_EQ(kStateRequestPacketSize, 32); + EXPECT_EQ(kGameStatePacketSize, 236); + EXPECT_EQ(kGameAckPacketSize, 40); + EXPECT_EQ(kMastermindStateRequestPacketSize, 32); + EXPECT_EQ(kPuzzleMaxTiles, 30); + EXPECT_EQ(kMastermindColorCount, 7); + EXPECT_EQ(kMastermindCodeLength, 4); + EXPECT_EQ(kMastermindMaxGuesses, 10); +} + +TEST(ProtocolTest, SequenceComparison) { + EXPECT_TRUE(isSequenceNewer(5, 4)); + EXPECT_TRUE(isSequenceNewer(1, 0)); + EXPECT_TRUE(isSequenceNewer(0, UINT32_MAX)); + EXPECT_FALSE(isSequenceNewer(4, 5)); + EXPECT_FALSE(isSequenceNewer(0, 0)); +} + +TEST(ProtocolTest, SessionTracking) { + uint32_t sessions[] = {100, 200, 300}; + EXPECT_TRUE(hasSeenSession(100, sessions, 3)); + EXPECT_TRUE(hasSeenSession(200, sessions, 3)); + EXPECT_FALSE(hasSeenSession(150, sessions, 3)); + EXPECT_FALSE(hasSeenSession(0, sessions, 3)); +} + +TEST(ProtocolTest, PeerSessionUsage) { + uint32_t retired[] = {150, 180}; + EXPECT_TRUE(canUsePeerSession(100, 100, false, retired, 2)); + EXPECT_TRUE(canUsePeerSession(100, 200, true, retired, 2)); + EXPECT_FALSE(canUsePeerSession(100, 150, true, retired, 2)); + EXPECT_FALSE(canUsePeerSession(100, 200, false, retired, 2)); + EXPECT_FALSE(canUsePeerSession(100, 0, true, retired, 2)); +} + +TEST(ProtocolTest, Enums) { + EXPECT_EQ(static_cast(MessageType::Heartbeat), 1); + EXPECT_EQ(static_cast(MessageType::PuzzleState), 5); + EXPECT_EQ(static_cast(MessageType::PuzzleAck), 6); + EXPECT_EQ(static_cast(MessageType::MastermindState), 7); + + EXPECT_EQ(static_cast(PuzzlePhase::Playing), 1); + EXPECT_EQ(static_cast(PuzzlePhase::Exited), 2); + + EXPECT_EQ(static_cast(MastermindPhase::Setup), 1); + EXPECT_EQ(static_cast(MastermindPhase::Playing), 2); + EXPECT_EQ(static_cast(MastermindPhase::RoundComplete), 3); + EXPECT_EQ(static_cast(MastermindPhase::Exited), 4); +} + +TEST(ProtocolTest, MastermindCode) { + MastermindCode first{1, 2, 3, 4}; + MastermindCode second{1, 2, 3, 4}; + MastermindCode diff{1, 2, 3, 5}; + + EXPECT_TRUE(sameMastermindCode(first, second)); + EXPECT_FALSE(sameMastermindCode(first, diff)); +} diff --git a/core/tests/reconciliation_test.cpp b/core/tests/reconciliation_test.cpp new file mode 100644 index 0000000..de4dc46 --- /dev/null +++ b/core/tests/reconciliation_test.cpp @@ -0,0 +1,167 @@ +/** + * Reconciliation Tests - State synchronization logic + */ + +#include +#include "flip7/protocol.hpp" + +using namespace flip7; + +TEST(ReconciliationTest, SequenceComparison) { + EXPECT_TRUE(isSequenceNewer(5, 4)); + EXPECT_TRUE(isSequenceNewer(1, 0)); + EXPECT_TRUE(isSequenceNewer(0, UINT32_MAX)); // wraparound + EXPECT_FALSE(isSequenceNewer(4, 5)); + EXPECT_FALSE(isSequenceNewer(5, 5)); +} + +TEST(ReconciliationTest, SessionTracking) { + uint32_t sessions[] = {0x11111111, 0x22222222, 0x33333333}; + EXPECT_TRUE(hasSeenSession(0x11111111, sessions, 3)); + EXPECT_TRUE(hasSeenSession(0x22222222, sessions, 3)); + EXPECT_FALSE(hasSeenSession(0x44444444, sessions, 3)); + EXPECT_FALSE(hasSeenSession(0, sessions, 3)); +} + +TEST(ReconciliationTest, PeerSessionLogic) { + EXPECT_TRUE(canUsePeerSession(0, 0x11111111, false, nullptr, 0)); + EXPECT_TRUE(canUsePeerSession(0x11111111, 0x11111111, false, nullptr, 0)); + EXPECT_FALSE(canUsePeerSession(0x11111111, 0x22222222, false, nullptr, 0)); + + uint32_t retired[] = {0x33333333}; + EXPECT_TRUE(canUsePeerSession(0x11111111, 0x22222222, true, retired, 1)); + EXPECT_FALSE(canUsePeerSession(0x11111111, 0x33333333, true, retired, 1)); +} + +TEST(ReconciliationTest, PuzzleVersionComparison) { + PuzzleState a{}, b{}; + a.gameId = 10; a.revision = 5; + b.gameId = 10; b.revision = 6; + EXPECT_EQ(comparePuzzleVersion(a, b), PuzzleVersionOrder::Newer); + + b.revision = 4; + EXPECT_EQ(comparePuzzleVersion(a, b), PuzzleVersionOrder::Older); + + b.revision = 5; + EXPECT_EQ(comparePuzzleVersion(a, b), PuzzleVersionOrder::Same); + + b.gameId = 11; + EXPECT_EQ(comparePuzzleVersion(a, b), PuzzleVersionOrder::Newer); +} + +TEST(ReconciliationTest, PuzzleDigestConsistency) { + PuzzleState state{}; + state.columns = 4; state.rows = 3; state.theme = PuzzleTheme::Greek; + state.phase = PuzzlePhase::Playing; + state.gameId = 100; state.revision = 200; state.turnBoardId = 300; + for (int i = 0; i < 12; ++i) state.tiles[i] = i + 1; + state.tiles[11] = 0; + + uint32_t d1 = puzzleStateDigest(state); + uint32_t d2 = puzzleStateDigest(state); + EXPECT_EQ(d1, d2); +} + +TEST(ReconciliationTest, PuzzleDeliverySuperseded) { + PuzzleState pending{}, observed{}; + pending.gameId = 10; pending.revision = 5; + observed.gameId = 10; observed.revision = 6; + EXPECT_TRUE(isDeliverySuperseded(pending, observed)); + + observed.revision = 4; + EXPECT_FALSE(isDeliverySuperseded(pending, observed)); + + pending.gameId = 11; + EXPECT_FALSE(isDeliverySuperseded(pending, observed)); +} + +TEST(ReconciliationTest, ReconciliationDecision) { + PuzzleState local{}; + local.gameId = 10; local.revision = 5; + + // Remote older -> send full state + auto action = decideReconciliation(local, 10, 4, 0x12345678, true); + EXPECT_EQ(action, ReconciliationAction::SendFullState); + + // Remote newer -> request full state + action = decideReconciliation(local, 10, 6, 0x12345678, true); + EXPECT_EQ(action, ReconciliationAction::RequestFullState); + + // Same revision, digest matches -> none + action = decideReconciliation(local, 10, 5, puzzleStateDigest(local), true); + EXPECT_EQ(action, ReconciliationAction::None); + + // Same revision, digest differs -> send if authority, request if not + action = decideReconciliation(local, 10, 5, 0xDEADBEEF, true); + EXPECT_EQ(action, ReconciliationAction::SendFullState); + action = decideReconciliation(local, 10, 5, 0xDEADBEEF, false); + EXPECT_EQ(action, ReconciliationAction::RequestFullState); +} + +TEST(ReconciliationTest, PuzzleMoveValidation) { + PuzzleSpec spec{3, 3, PuzzleTheme::Planets}; + PuzzleState state = makeInitialPuzzle(0x12345678, 0x87654321, spec); + state = makeScrambledPuzzle(0x12345678, 0x87654321, spec); + + // Find a valid move - just try all positions and find one that works + uint8_t movedPos = 255; + for (uint8_t pos = 0; pos < 9; ++pos) { + PuzzleState testState = state; + if (tryPuzzleMove(testState, pos, testState.turnBoardId, 0xB0CBD8E7)) { + movedPos = pos; + break; + } + } + ASSERT_NE(movedPos, 255) << "Should find at least one valid move"; + + // Valid move + bool ok = tryPuzzleMove(state, movedPos, state.turnBoardId, 0xB0CBD8E7); + EXPECT_TRUE(ok); + EXPECT_EQ(state.revision, 1); + EXPECT_EQ(state.turnBoardId, 0xB0CBD8E7); + + // Invalid: not adjacent (try a different position that wasn't valid) + PuzzleState state2 = state; + uint8_t invalidPos = 255; + for (uint8_t pos = 0; pos < 9; ++pos) { + if (pos != movedPos) { + PuzzleState testState = state2; + if (!tryPuzzleMove(testState, pos, testState.turnBoardId, 0xD4E9F46A)) { + invalidPos = pos; + break; + } + } + } + if (invalidPos != 255) { + ok = tryPuzzleMove(state2, invalidPos, state2.turnBoardId, 0xD4E9F46A); + EXPECT_FALSE(ok); + } + + // Invalid: moving blank + PuzzleState state3 = state; + uint8_t blank = findBlank(state3); + ok = tryPuzzleMove(state3, blank, state3.turnBoardId, 0xB0CBD8E7); + EXPECT_FALSE(ok); +} + +TEST(ReconciliationTest, PuzzleExitSignal) { + PuzzleSpec spec{3, 3, PuzzleTheme::Planets}; + PuzzleState state = makeInitialPuzzle(0x12345678, 0x87654321, spec); + state = makeScrambledPuzzle(0x12345678, 0x87654321, spec); + + PuzzleState exited = state; + bool ok = exitPuzzle(exited, 0x12345678, 0xB0CBD8E7); + EXPECT_TRUE(ok); + EXPECT_EQ(exited.phase, PuzzlePhase::Exited); + + // Apply exit signal from peer + PuzzleState current = state; + bool ok2 = applyPuzzleExitSignal(current, exited, 0x12345678, 0xB0CBD8E7); + EXPECT_TRUE(ok2); + EXPECT_EQ(current.phase, PuzzlePhase::Exited); + + // Applying same exit signal again should succeed (idempotent) + PuzzleState current2 = exited; + bool ok3 = applyPuzzleExitSignal(current2, exited, 0x12345678, 0xB0CBD8E7); + EXPECT_TRUE(ok3); +} \ No newline at end of file diff --git a/core/tests/serialization_test.cpp b/core/tests/serialization_test.cpp new file mode 100644 index 0000000..cb7bbfd --- /dev/null +++ b/core/tests/serialization_test.cpp @@ -0,0 +1,219 @@ +/** + * Serialization Tests - Round-trip encoding/decoding + */ + +#include +#include "flip7/protocol.hpp" +#include "flip7/serializer.hpp" + +using namespace flip7; + +TEST(SerializationTest, PacketHeaderRoundTrip) { + PacketHeader header{}; + header.magic = kProtocolMagic; + header.version = kProtocolVersion; + header.type = MessageType::Heartbeat; + header.senderId = 0x12345678; + header.sessionId = 0x87654321; + header.sequence = 42; + + uint8_t buffer[20]; + uint8_t* dst = buffer; + serialize(header, dst); + + const uint8_t* src = buffer; + PacketHeader decoded{}; + deserialize(src, decoded); + + EXPECT_EQ(decoded.magic, header.magic); + EXPECT_EQ(decoded.version, header.version); + EXPECT_EQ(decoded.type, header.type); + EXPECT_EQ(decoded.senderId, header.senderId); + EXPECT_EQ(decoded.sessionId, header.sessionId); + EXPECT_EQ(decoded.sequence, header.sequence); +} + +TEST(SerializationTest, HeartbeatPacketRoundTrip) { + HeartbeatPacket packet{}; + packet.header.magic = kProtocolMagic; + packet.header.version = kProtocolVersion; + packet.header.type = MessageType::Heartbeat; + packet.header.senderId = 0xD4E9F46A; + packet.header.sessionId = 0xAABBCCDD; + packet.header.sequence = 1; + packet.uptimeMs = 123456; + + uint8_t buffer[24]; + uint8_t* dst = buffer; + serialize(packet, dst); + + const uint8_t* src = buffer; + HeartbeatPacket decoded{}; + deserialize(src, decoded); + + EXPECT_EQ(decoded.header.magic, packet.header.magic); + EXPECT_EQ(decoded.header.type, packet.header.type); + EXPECT_EQ(decoded.uptimeMs, packet.uptimeMs); +} + +TEST(SerializationTest, PuzzleStatePacketRoundTrip) { + PuzzleStatePacket packet{}; + packet.header.magic = kProtocolMagic; + packet.header.version = kProtocolVersion; + packet.header.type = MessageType::PuzzleState; + packet.header.senderId = 0xD4E9F46A; + packet.header.sessionId = 0xAABBCCDD; + packet.header.sequence = 1; + packet.state.columns = 3; + packet.state.rows = 3; + packet.state.theme = PuzzleTheme::Planets; + packet.state.phase = PuzzlePhase::Playing; + packet.state.gameId = 0x87654321; + packet.state.revision = 10; + packet.state.turnBoardId = 0xB0CBD8E7; + for (int i = 0; i < 9; ++i) packet.state.tiles[i] = i + 1; + packet.state.tiles[8] = 0; + + uint8_t buffer[68]; + uint8_t* dst = buffer; + serialize(packet, dst); + + const uint8_t* src = buffer; + PuzzleStatePacket decoded{}; + deserialize(src, decoded); + + EXPECT_EQ(decoded.header.magic, packet.header.magic); + EXPECT_EQ(decoded.header.type, packet.header.type); + EXPECT_EQ(decoded.state.columns, packet.state.columns); + EXPECT_EQ(decoded.state.theme, packet.state.theme); + EXPECT_EQ(decoded.state.gameId, packet.state.gameId); + for (int i = 0; i < 30; ++i) { + EXPECT_EQ(decoded.state.tiles[i], packet.state.tiles[i]); + } +} + +TEST(SerializationTest, PuzzleAckPacketRoundTrip) { + PuzzleAckPacket packet{}; + packet.header.magic = kProtocolMagic; + packet.header.version = kProtocolVersion; + packet.header.type = MessageType::PuzzleAck; + packet.header.senderId = 0xD4E9F46A; + packet.header.sessionId = 0xAABBCCDD; + packet.header.sequence = 5; + packet.targetBoardId = 0xB0CBD8E7; + packet.gameId = 0x87654321; + packet.revision = 10; + packet.stateDigest = 0xDEADBEEF; + packet.acknowledgedType = MessageType::PuzzleState; + packet.reserved[0] = 0; + packet.reserved[1] = 0; + packet.reserved[2] = 0; + + uint8_t buffer[40]; + uint8_t* dst = buffer; + serialize(packet, dst); + + const uint8_t* src = buffer; + PuzzleAckPacket decoded{}; + deserialize(src, decoded); + + EXPECT_EQ(decoded.header.magic, packet.header.magic); + EXPECT_EQ(decoded.targetBoardId, packet.targetBoardId); + EXPECT_EQ(decoded.gameId, packet.gameId); + EXPECT_EQ(decoded.stateDigest, packet.stateDigest); +} + +TEST(SerializationTest, StateRequestPacketRoundTrip) { + StateRequestPacket packet{}; + packet.header.magic = kProtocolMagic; + packet.header.version = kProtocolVersion; + packet.header.type = MessageType::RequestState; + packet.header.senderId = 0xD4E9F46A; + packet.header.sessionId = 0xAABBCCDD; + packet.header.sequence = 10; + packet.gameId = 0x12345678; + packet.revision = 20; + packet.stateDigest = 0xCAFEBABE; + + uint8_t buffer[32]; + uint8_t* dst = buffer; + serialize(packet, dst); + + const uint8_t* src = buffer; + StateRequestPacket decoded{}; + deserialize(src, decoded); + + EXPECT_EQ(decoded.gameId, packet.gameId); + EXPECT_EQ(decoded.revision, packet.revision); + EXPECT_EQ(decoded.stateDigest, packet.stateDigest); +} + +TEST(SerializationTest, GameStatePacketRoundTrip) { + GameStatePacket packet{}; + packet.header.magic = kProtocolMagic; + packet.header.version = kProtocolVersion; + packet.header.type = MessageType::MastermindState; + packet.header.senderId = 0xD4E9F46A; + packet.header.sessionId = 0xAABBCCDD; + packet.header.sequence = 100; + packet.state.guessCount = 3; + packet.state.codemakerBoardId = 0xD4E9F46A & 0xFF; + packet.state.phase = MastermindPhase::Playing; + packet.state.gameId = 0x11111111; + packet.state.revision = 5; + packet.state.hostBoardId = 0xD4E9F46A; + packet.state.guestBoardId = 0xB0CBD8E7; + packet.state.secret.colors[0] = 1; + packet.state.secret.colors[1] = 2; + packet.state.secret.colors[2] = 3; + packet.state.secret.colors[3] = 4; + + uint8_t buffer[236]; + uint8_t* dst = buffer; + serialize(packet, dst); + + const uint8_t* src = buffer; + GameStatePacket decoded{}; + deserialize(src, decoded); + + EXPECT_EQ(decoded.header.magic, packet.header.magic); + EXPECT_EQ(decoded.header.type, packet.header.type); + EXPECT_EQ(decoded.state.guessCount, packet.state.guessCount); + EXPECT_EQ(decoded.state.phase, packet.state.phase); + EXPECT_EQ(decoded.state.gameId, packet.state.gameId); + for (int i = 0; i < 4; ++i) { + EXPECT_EQ(decoded.state.secret.colors[i], packet.state.secret.colors[i]); + } +} + +TEST(SerializationTest, PeekPacketType) { + // Valid packet + uint8_t buffer[68]; + uint8_t* dst = buffer; + PuzzleStatePacket pkt{}; + pkt.header.magic = kProtocolMagic; + pkt.header.version = kProtocolVersion; + pkt.header.type = MessageType::PuzzleState; + serialize(pkt, dst); + + MessageType type = peekPacketType(buffer, sizeof(buffer)); + EXPECT_EQ(type, MessageType::PuzzleState); + + // Wrong magic + buffer[0] = 0xFF; + MessageType bad = peekPacketType(buffer, sizeof(buffer)); + EXPECT_EQ(static_cast(bad), 0); + + // Wrong version + buffer[0] = static_cast(kProtocolMagic & 0xFF); + buffer[1] = static_cast((kProtocolMagic >> 8) & 0xFF); + buffer[2] = static_cast((kProtocolMagic >> 16) & 0xFF); + buffer[3] = static_cast((kProtocolMagic >> 24) & 0xFF); + buffer[4] = 99; // wrong version + MessageType badVer = peekPacketType(buffer, sizeof(buffer)); + EXPECT_EQ(static_cast(badVer), 0); + + // Too short + MessageType shortLen = peekPacketType(buffer, 5); + EXPECT_EQ(static_cast(shortLen), 0); +} \ No newline at end of file diff --git a/dist/web-installer/firmware/flip7-cyd-scorekeeper-cyd.bin b/dist/web-installer/firmware/flip7-cyd-scorekeeper-cyd.bin new file mode 100644 index 0000000..fa9941f Binary files /dev/null and b/dist/web-installer/firmware/flip7-cyd-scorekeeper-cyd.bin differ diff --git a/dist/web-installer/index.html b/dist/web-installer/index.html new file mode 100644 index 0000000..d79a592 --- /dev/null +++ b/dist/web-installer/index.html @@ -0,0 +1,211 @@ + + + + + + Flip7 CYD Scorekeeper Flasher + + + + + +
+

Flip7 CYD Scorekeeper

+

Flash the paired CYD game firmware directly from your browser.

+ +
>)))'> oO0 +~ ~ ~ ~ ~ ~ ~ ~ ~ + <o(((< *
+ +
    +
  1. 1. Flash CYD 1Connect one board with a data USB cable and flash the first slot.
  2. +
  3. 2. Swap boardsUnplug CYD 1, plug in CYD 2, and keep this page open.
  4. +
  5. 3. Flash CYD 2Flash the second slot. Both boards receive the same paired firmware.
  6. +
+ +
+
+
+

CYD 1

+

Flash the first ESP32-2432S028R Cheap Yellow Display in the Flip7 scorekeeper pair.

+
+ + + Use Chrome, Edge, or Firefox 151 or newer on a desktop computer with WebSerial enabled. + This page needs HTTPS to flash your board. + +
+ +
+
+

CYD 2

+

After CYD 1 finishes, unplug it, connect the second board, and flash the same firmware.

+
+ + + Use Chrome, Edge, or Firefox 151 or newer on a desktop computer with WebSerial enabled. + This page needs HTTPS to flash your board. + +
+
+ +

+ WebSerial flashes one connected CYD at a time. Close any serial monitor before flashing. + Firmware built by CI uses ephemeral ESP-NOW keys; build locally with your real keys for the paired private devices. +

+
+ + diff --git a/dist/web-installer/manifest-cyd.json b/dist/web-installer/manifest-cyd.json new file mode 100644 index 0000000..c51a551 --- /dev/null +++ b/dist/web-installer/manifest-cyd.json @@ -0,0 +1,16 @@ +{ + "name": "Flip7 CYD Scorekeeper", + "version": "3b5354a5d1c4", + "new_install_prompt_erase": true, + "builds": [ + { + "chipFamily": "ESP32", + "parts": [ + { + "path": "firmware/flip7-cyd-scorekeeper-cyd.bin", + "offset": 0 + } + ] + } + ] +} diff --git a/docs/Flip7 CYD Scorekeeper Architecture.docx b/docs/Flip7 CYD Scorekeeper Architecture.docx new file mode 100644 index 0000000..80086d2 Binary files /dev/null and b/docs/Flip7 CYD Scorekeeper Architecture.docx differ diff --git a/docs/Flip7 CYD Scorekeeper Architecture.md b/docs/Flip7 CYD Scorekeeper Architecture.md new file mode 100644 index 0000000..e5078e2 --- /dev/null +++ b/docs/Flip7 CYD Scorekeeper Architecture.md @@ -0,0 +1,778 @@ +# Flip7 CYD Scorekeeper +## Android Companion and Bluetooth Integration +### Solution Architecture Document +Project: flip7-cyd-scorekeeper +Repository: abandonrules/flip7-cyd-scorekeeper +Status: Proposed Architecture +Primary platforms: ESP32-2432S028 CYD boards and Android +Primary languages: C++, Kotlin +Communication: ESP-NOW and Bluetooth Low Energy + +## 1. Executive Summary +The Flip7 CYD Scorekeeper currently consists of two ESP32-2432S028 Cheap Yellow Display boards that synchronize games over encrypted ESP-NOW. +Both CYD boards have now been verified to support: +Bluetooth Low Energy +Bluetooth Classic +Dual-mode Bluetooth +Wi-Fi +ESP-NOW +Dual-core ESP32 execution +The proposed architecture adds an Android companion application without replacing the existing CYD-to-CYD ESP-NOW connection. +The Android application will communicate with one or both CYD boards using Bluetooth Low Energy. The existing C++ game rules, state validation, packet serialization, reconciliation logic, and protocol definitions will be extracted into a shared platform-independent core. +The shared C++ core will be compiled directly into: +The ESP32 firmware. +The Android APK through the Android NDK and JNI. +This approach provides one authoritative implementation of the game protocol and game rules while allowing each platform to maintain its own user interface and communication code. + +## 2. Goals +### 2.1 Primary Goals +Allow an Android application to communicate with the CYD boards. +Preserve the existing ESP-NOW connection between the two CYDs. +Reuse the existing C++ game and synchronization logic. +Prevent the Android and ESP32 implementations from diverging. +Support Android as either a player or spectator. +Keep game logic independent of BLE and ESP-NOW. +Establish a reusable architecture for future synchronized games. +Preserve offline operation between the CYD boards. +### 2.2 Secondary Goals +Support match history and statistics. +Allow Android to display synchronized game state. +Support future persistence and cloud backup. +Allow future transports such as Wi-Fi, USB, or WebSocket. +Make protocol behavior testable on desktop, Android, and ESP32. +Keep Android-specific code primarily in Kotlin. + +## 3. Non-Goals +The first implementation will not: +Replace ESP-NOW with Bluetooth. +Require internet access. +Require a cloud service. +Move the Android user interface into C++. +Expose raw C++ pointers or native structs to Kotlin. +Depend on Bluetooth Classic. +Support arbitrary untrusted CYD devices. +Guarantee resistance against modified firmware or physical device extraction. +Rewrite the shared core in Rust or Kotlin Multiplatform. + +## 4. Current System +The existing system contains two CYD boards connected through ESP-NOW. +┌──────────────────┐ ESP-NOW ┌──────────────────┐ +│ │◀─────────────────────────▶│ │ +│ CYD Board A │ │ CYD Board B │ +│ │ │ │ +└──────────────────┘ └──────────────────┘ +The boards synchronize: +Heartbeats +Active game selection +Puzzle state +Mastermind state +Revisions and epochs +Exit state +Recovery state +Game-specific actions +The Android application does not currently have a compatible transport into this network. + +## 5. Proposed System +One or both CYD boards will expose a BLE service that acts as a packet transport for the existing Flip7 protocol. +Bluetooth Low Energy + ┌────────────────────────────┐ + │ │ + ▼ │ + ┌───────────────┐ │ + │ │ │ + │ Android APK │ │ + │ │ │ + └───────────────┘ │ + │ + ▼ +┌──────────────────┐ ESP-NOW ┌──────────────────┐ +│ │◀─────────────────────────▶│ │ +│ CYD Board A │ │ CYD Board B │ +│ │ │ │ +└──────────────────┘ └──────────────────┘ +The Android application sends and receives the same logical protocol packets used by the CYD system. +BLE and ESP-NOW remain transport mechanisms only. +Neither transport owns the game rules. + +## 6. Architectural Principles +### 6.1 One Authoritative Core +Game rules and protocol behavior must exist in one shared C++ implementation. +The same source code will be compiled for both ESP32 and Android. +### 6.2 Transport Independence +Game logic must not directly call: +ESP-NOW APIs +BLE APIs +Android APIs +Arduino APIs +LVGL APIs +Wi-Fi APIs +Instead, transports deliver serialized packets to the shared core. +handleIncomingPacket(packetData, packetLength); +### 6.3 Explicit Serialization +Native C++ structs must not be copied directly onto the wire. +Unsafe example: +memcpy(buffer, &state, sizeof(state)); +The protocol must explicitly define: +Integer width +Field order +Byte order +Packet version +Packet length +Packet type +Validation rules +Optional checksum or digest +Example: +writeU32LE(output, state.gameId); +writeU32LE(output, state.revision); +writeU8(output, static_cast(state.phase)); +### 6.4 Narrow JNI Boundary +The JNI interface should move bytes and simple values rather than expose the complete C++ object model. +Preferred JNI inputs and outputs: +ByteArray +Boolean +Int +Long +Small immutable result objects +JSON or another UI projection where appropriate +### 6.5 Platform-Owned User Interfaces +The ESP32 uses its existing display and touch interface. +Android uses native Android UI, preferably Kotlin with Jetpack Compose. +The shared C++ core does not render screens. + +## 7. Logical Architecture +┌─────────────────────────────────────────────────────────────┐ +│ UI Layer │ +├──────────────────────────────┬──────────────────────────────┤ +│ ESP32 LVGL / Touch UI │ Android Jetpack Compose UI │ +└──────────────────────────────┴──────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +├─────────────────────────────────────────────────────────────┤ +│ Start game │ +│ Join game │ +│ Submit move │ +│ Submit Mastermind guess │ +│ Exit game │ +│ Request snapshot │ +│ Observe game │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Shared C++ Core │ +├─────────────────────────────────────────────────────────────┤ +│ Game rules │ +│ State validation │ +│ Packet serialization │ +│ Packet deserialization │ +│ Revision comparison │ +│ Epoch management │ +│ Reconciliation decisions │ +│ Digest calculation │ +│ Puzzle generation │ +│ Move validation │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Transport Layer │ +├──────────────────────────────┬──────────────────────────────┤ +│ ESP-NOW │ Bluetooth Low Energy │ +│ CYD ↔ CYD │ Android ↔ CYD │ +└──────────────────────────────┴──────────────────────────────┘ + +## 8. Shared C++ Core +The shared core should contain platform-neutral code only. +### 8.1 Suggested Modules +core/ +├── include/ +│ └── flip7/ +│ ├── protocol.hpp +│ ├── packet.hpp +│ ├── serializer.hpp +│ ├── reconciliation.hpp +│ ├── active_game.hpp +│ ├── mastermind.hpp +│ ├── puzzle.hpp +│ ├── aquarium.hpp +│ └── result.hpp +├── src/ +│ ├── packet.cpp +│ ├── serializer.cpp +│ ├── reconciliation.cpp +│ ├── mastermind.cpp +│ ├── puzzle.cpp +│ └── aquarium.cpp +└── tests/ + ├── protocol_test.cpp + ├── serialization_test.cpp + ├── reconciliation_test.cpp + ├── mastermind_test.cpp + └── puzzle_test.cpp +### 8.2 Responsibilities +The shared core will: +Validate incoming packets. +Decode protocol messages. +Apply valid messages to local state. +Reject malformed or stale messages. +Generate outgoing packets. +Calculate packet digests. +Compare revisions and epochs. +Determine reconciliation actions. +Execute game actions. +Validate state invariants. +Produce safe display projections. +### 8.3 Prohibited Dependencies +The shared core must not include: +#include +#include +#include +#include +#include +#include +Platform wrappers may include these dependencies, but the core must not. + +## 9. BLE Architecture +### 9.1 BLE Role +The CYD acts as a BLE peripheral. +The Android application acts as a BLE central. +### 9.2 BLE Service +The firmware will expose one custom Flip7 service. +Suggested conceptual design: +Flip7 Service +├── Command characteristic +├── Event characteristic +└── Device information characteristic +A minimal first version may use only two packet characteristics: +### Command Characteristic +Android writes packets to the CYD. +Supports write with response initially. +Used for actions, requests, and state updates. +### Event Characteristic +CYD sends notifications to Android. +Used for snapshots, acknowledgments, errors, and game events. +### Device Information Characteristic +Optional read-only characteristic containing: +Protocol version +Firmware version +Board identifier +Device role +Active game +Feature flags +### 9.3 Why Two Packet Characteristics +Using separate command and event characteristics makes direction and permissions clear while retaining a generic packet-oriented design. +Android ──write──▶ Command +Android ◀─notify── Event +### 9.4 Packet Fragmentation +BLE payload capacity may be smaller than some protocol messages. +The transport layer must support: +Negotiated MTU +Packet length prefix +Message identifier +Fragment index +Fragment count +Reassembly timeout +Duplicate fragment handling +Maximum message size +The shared game core should receive only a fully reassembled packet. +BLE fragments + ↓ +BLE reassembly + ↓ +Complete Flip7 packet + ↓ +Shared C++ core + +## 10. CYD Gateway Behavior +### 10.1 Preferred Initial Design +Both boards may advertise BLE, but the Android application connects to only one board at a time. +The connected board becomes the Android gateway. +Android + │ + │ BLE + ▼ +CYD A + │ + │ ESP-NOW + ▼ +CYD B +### 10.2 Gateway Responsibilities +The BLE-connected CYD will: +Receive packets from Android. +Validate them. +Apply valid actions locally. +Forward relevant state through ESP-NOW. +Forward synchronized state changes back to Android. +Prevent packet loops. +Track packet origin. +Avoid applying the same message twice. +### 10.3 Packet Origin +Each message should identify its source. +Possible origin types: +enum class PacketOrigin : uint8_t { + Unknown = 0, + CydHost = 1, + CydGuest = 2, + Android = 3, +}; +Each packet should also include a unique message identifier or an origin-specific sequence number. +This prevents the following loop: +Android → CYD A → CYD B → CYD A → Android → CYD A +### 10.4 Gateway Failover +The first release does not require seamless gateway failover. +A later release may allow Android to reconnect to the surviving CYD if the original gateway disconnects. + +## 11. Android Architecture +### 11.1 Suggested Android Stack +Kotlin +Jetpack Compose +Android BLE APIs +Android NDK +CMake +JNI +Kotlin coroutines +StateFlow +Repository pattern +### 11.2 Android Layers +Android UI + ↓ +ViewModel + ↓ +Game Repository + ├── BLE Transport + └── Native Core Adapter + ↓ + JNI + ↓ + Shared C++ Core +### 11.3 Android Responsibilities +The Android application will: +Scan for Flip7 CYD devices. +Display discovered boards. +Connect to a selected board. +Discover the Flip7 BLE service. +Negotiate notifications. +Reassemble BLE fragments. +Pass complete packets to the native core. +Display validated state. +Create user actions. +Ask the native core to generate valid outgoing packets. +Send packets through BLE. +Manage Android lifecycle events. +Handle reconnection. +Store optional local history and preferences. +### 11.4 Native Interface +Suggested first JNI API: +object Flip7Native { + init { + System.loadLibrary("flip7core") + } + + external fun protocolVersion(): Int + + external fun validatePacket(packet: ByteArray): Boolean + + external fun decodePacket(packet: ByteArray): ByteArray + + external fun applyIncomingPacket( + currentState: ByteArray, + packet: ByteArray + ): ByteArray + + external fun createActionPacket( + currentState: ByteArray, + action: ByteArray + ): ByteArray + + external fun stateForDisplay( + currentState: ByteArray + ): String +} +This API is illustrative. The exact interface should be refined while implementing the shared state container. + +## 12. Android Operating Modes +### 12.1 Spectator Mode +Android receives state but cannot modify the game. +Possible features: +Current game display +Scores +Puzzle progress +Mastermind guesses +Round history +Connection health +Match timer +Statistics +This should be the first Android operating mode because it has the lowest synchronization risk. +### 12.2 Player Mode +Android behaves as a participating client. +Possible actions: +Start a game +Join a game +Submit a move +Enter a code +Submit a guess +Advance a round +Exit a game +Player mode requires authorization and stricter conflict handling. +### 12.3 Controller Mode +Android controls the two-board system without representing a player. +Possible actions: +Select game +Reset match +Configure players +Manage settings +Export history +Controller mode may be introduced after spectator mode. + +## 13. Protocol Requirements +Every packet should contain a stable envelope. +Suggested logical packet envelope: +struct PacketHeader { + uint16_t magic; + uint8_t protocolVersion; + uint8_t packetType; + uint16_t payloadLength; + uint32_t messageId; + uint32_t senderId; + uint32_t epoch; + uint32_t revision; +}; +The actual wire representation must be serialized explicitly and must not depend on struct layout. +### 13.1 Required Fields +Protocol magic +Protocol version +Packet type +Payload length +Message identifier +Sender identifier +Game identifier where applicable +Epoch +Revision +Payload +Integrity check +### 13.2 Protocol Versioning +Receivers must reject unsupported major versions. +Minor versions may be accepted when backward compatible. +Example: +Major version change: +Breaking packet structure or behavior + +Minor version change: +New optional packet or capability +### 13.3 Capability Exchange +During connection, Android and the CYD should exchange capabilities. +Examples: +Supported protocol version +Supported games +Spectator support +Player support +Maximum packet size +Fragmentation support +Compression support +Firmware version +Native core version + +## 14. Synchronization Model +The CYD pair remains the authoritative game system in the first release. +Android receives synchronized snapshots and submits actions. +Android should not overwrite the entire game state unless explicitly participating in recovery. +### 14.1 Action-Based Writes +Preferred: +Android sends: +"Submit guess: red, blue, green, red" +Not preferred: +Android sends: +"Replace the entire Mastermind state with this object" +The authoritative core validates the action and produces the resulting state. +### 14.2 Snapshots +CYDs send snapshots: +On Android connection +After accepted actions +After reconciliation +After active game changes +After peer recovery +On explicit snapshot request +Periodically when appropriate +### 14.3 Conflict Resolution +Conflict resolution uses: +Game epoch +State revision +Packet validity +Active game identity +Deterministic reconciliation rules +Sender role where necessary +All platforms must use the shared C++ implementation for these decisions. + +## 15. Security +### 15.1 BLE Pairing +The initial implementation should require BLE bonding or an application-level pairing code before player or controller actions are accepted. +Spectator access may optionally be less restrictive. +### 15.2 Authorization Levels +Suggested roles: +Spectator +Player +Controller +Administrator +### 15.3 Sensitive Information +Mastermind secrets must not be exposed to an Android spectator before the round ends. +The protocol should provide a sanitized view for unauthorized clients. +The shared core should generate role-specific projections rather than relying only on the UI to hide sensitive fields. +### 15.4 Replay Protection +The system should reject: +Duplicate message identifiers +Old sequence numbers +Stale revisions +Packets from previous epochs +Invalid sender identifiers +Packets with bad integrity checks +### 15.5 Existing ESP-NOW Keys +Existing ESP-NOW PMK and LMK values remain outside the repository. +BLE credentials, bonds, or application keys must also not be committed. + +## 16. Reliability +### 16.1 Reconnection +When Android reconnects: +Discover the Flip7 BLE service. +Read device information. +Subscribe to event notifications. +Request the active state snapshot. +Validate the snapshot through the shared core. +Resume spectator or player mode. +### 16.2 Brownout Considerations +Bluetooth and display activity can increase peak current demand. +The firmware should: +Avoid unnecessary simultaneous radio operations. +Monitor resets and brownout causes. +Use a reliable USB power supply. +Avoid powering high-load peripherals from marginal USB ports. +Log Bluetooth initialization failures. +Recover cleanly after controller initialization errors. +### 16.3 Watchdogs and Timeouts +The BLE layer should define: +Connection timeout +Fragment reassembly timeout +Action acknowledgment timeout +Snapshot request timeout +Reconnection backoff +Maximum retry count + +## 17. Repository Structure +Recommended repository organization: +flip7-cyd-scorekeeper/ +├── core/ +│ ├── include/ +│ ├── src/ +│ └── tests/ +│ +├── firmware/ +│ ├── src/ +│ ├── include/ +│ └── transports/ +│ ├── espnow_transport.cpp +│ └── ble_transport.cpp +│ +├── android/ +│ ├── app/ +│ │ ├── src/main/java/ +│ │ ├── src/main/cpp/ +│ │ │ ├── CMakeLists.txt +│ │ │ └── flip7_jni.cpp +│ │ └── src/test/ +│ └── build.gradle.kts +│ +├── protocol/ +│ ├── specification.md +│ ├── packets.md +│ └── golden/ +│ +├── test/ +├── platformio.ini +└── README.md +The repository may retain its existing structure during migration. This represents the desired end state. + +## 18. Testing Strategy +### 18.1 Shared Core Unit Tests +Test: +Packet encoding +Packet decoding +State validation +Mastermind scoring +Puzzle move validation +Puzzle scrambling +Epoch transitions +Revision handling +Reconciliation +Duplicate detection +Role-specific projections +### 18.2 Golden Packet Tests +Store known protocol examples: +Input state +Expected exact packet bytes +Expected decoded state +Expected digest +The same golden data should be tested on: +Desktop C++ +ESP32 +Android JNI +### 18.3 BLE Tests +Test: +Discovery +Connection +Service discovery +Notification subscription +Write acknowledgment +MTU variation +Fragmentation +Reassembly +Disconnect during transfer +Duplicate fragment +Missing fragment +Reconnection +Multiple nearby CYDs +### 18.4 Integration Tests +Required scenarios: +Two CYDs continue playing with no Android device. +Android connects as spectator. +Android disconnects without interrupting the CYD game. +Android reconnects and receives current state. +Android sends a valid action. +Android sends an invalid action. +The BLE gateway CYD restarts. +The non-gateway CYD restarts. +One CYD recovers state from the other. +Android receives the reconciled state. +Android attempts to replay an old packet. +Android receives a Mastermind view without the hidden secret. +### 18.5 Fuzz and Malformed Input Tests +Test: +Truncated packets +Invalid lengths +Unsupported versions +Unknown packet types +Invalid enums +Oversized messages +Invalid digests +Invalid fragment counts +Impossible state combinations +Unexpected reserved bytes + +## 19. Delivery Phases +### Phase 1 — Core Extraction +Identify platform-neutral C++ logic. +Move it into a shared core. +Remove Arduino and ESP32 dependencies. +Preserve current firmware behavior. +Add desktop unit tests. +Exit criteria: Existing CYD firmware builds and behaves identically using the extracted core. +### Phase 2 — Protocol Hardening +Define explicit packet serialization. +Add packet versioning. +Add packet integrity checks. +Add message identifiers. +Add origin tracking. +Create golden packet tests. +Exit criteria: Packet encoding is deterministic across desktop and ESP32 builds. +### Phase 3 — BLE Spectator Transport +Add the Flip7 BLE service. +Add command and event characteristics. +Implement fragmentation and reassembly. +Send snapshots to Android. +Keep Android read-only. +Exit criteria: Android can connect, reconnect, and display live synchronized game state. +### Phase 4 — Android Native Core +Configure Android NDK and CMake. +Compile the shared core into the APK. +Add the JNI adapter. +Run golden protocol tests on Android. +Validate incoming packets natively. +Exit criteria: Android and ESP32 produce matching protocol results. +### Phase 5 — Android Player Actions +Add action packet types. +Add authentication and role assignment. +Validate Android actions. +Forward accepted changes through ESP-NOW. +Add conflict and retry handling. +Exit criteria: Android can participate without causing state divergence. +### Phase 6 — Persistence and History +Save match history on Android. +Add export. +Add optional CYD persistence. +Add statistics and dashboards. +Exit criteria: Completed games survive Android restarts and can be reviewed. + +## 20. Risks and Mitigations + +## 21. Architectural Decisions +### ADR-001: Retain ESP-NOW Between CYDs +Decision: ESP-NOW remains the primary CYD-to-CYD transport. +Reason: It is already implemented, tested, encrypted, and supports offline board synchronization. +### ADR-002: Use BLE for Android Communication +Decision: Android communicates with a CYD using BLE. +Reason: Both boards have verified BLE support, and Android provides native BLE APIs. +### ADR-003: Share the C++ Core Through JNI +Decision: The Android APK compiles and invokes the shared C++ core using NDK and JNI. +Reason: This avoids duplicated game and protocol implementations. +### ADR-004: Do Not Serialize Native Struct Memory +Decision: All protocol fields use explicit serialization. +Reason: Native struct layouts may differ across ESP32 and Android architectures. +### ADR-005: Start With Spectator Mode +Decision: The first Android release is read-only. +Reason: This validates BLE transport and JNI reuse before introducing multi-writer synchronization. +### ADR-006: Keep CYDs Authoritative +Decision: The CYD pair remains authoritative during the initial Android releases. +Reason: The boards must continue operating when the phone disconnects. +### ADR-007: Use BLE Rather Than Bluetooth Classic +Decision: BLE is the default Android transport. +Reason: BLE provides a structured service model, broad Android support, low-power operation, and clean notification-based state updates. + +## 22. Definition of Done +The architecture is considered successfully implemented when: +Both CYDs continue synchronizing over ESP-NOW. +Android discovers and connects to either CYD over BLE. +Android receives the current active game state. +Android reconnects after leaving BLE range. +Android and ESP32 compile the same shared C++ core. +Golden packet tests pass on Android and desktop. +Packets are serialized explicitly. +Android spectator mode does not expose protected game information. +BLE failure does not interrupt CYD-to-CYD play. +Duplicate and stale messages are rejected. +The firmware remains stable under simultaneous BLE and ESP-NOW activity. +Documentation explains the protocol, BLE service, and JNI interface. + +## 23. Recommended Initial Work Items +Extract Mastermind, puzzle, active-game, and reconciliation logic into core/. +Define a versioned packet envelope. +Replace raw struct transmission with explicit serialization. +Add golden packet fixtures. +Add a BLE device-information service. +Add command and event packet characteristics. +Implement a read-only Android BLE scanner and connection screen. +Build the shared core through Android CMake. +Add a minimal JNI packet validator. +Display the synchronized active game in spectator mode. + +## 24. Final Recommendation +Implement the Android companion using: +ESP-NOW for CYD-to-CYD synchronization. +BLE for Android-to-CYD communication. +A shared C++ core for protocol and game logic. +JNI and the Android NDK for native code reuse. +Kotlin and Jetpack Compose for Android-specific behavior. +Explicit, versioned packet serialization. +A spectator-first delivery strategy. +This architecture introduces Android support without weakening the existing offline CYD system and creates a foundation for future games, transports, dashboards, and client platforms. + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| BLE and ESP-NOW compete for radio time | High | Keep packets small, queue transmissions, test under load | +| Native struct layouts differ | High | Use explicit serialization | +| Android and ESP32 behavior diverges | High | Compile the same C++ core on both | +| JNI becomes overly complex | Medium | Keep a narrow byte-oriented API | +| Packet forwarding causes loops | High | Add origin and message identifiers | +| Android sees hidden game information | High | Generate role-specific projections in the core | +| BLE disconnects during a game | Medium | CYDs remain authoritative and continue offline | +| Brownouts occur during radio use | Medium | Improve power delivery and log reset causes | +| Firmware size increases | Medium | Track binary size and disable unused Bluetooth Classic | +| BLE packet size is insufficient | Medium | Implement transport fragmentation | +| Multiple phones connect simultaneously | Medium | Limit initial release to one Android connection | +| Old clients send incompatible packets | Medium | Enforce protocol versions and capabilities | \ No newline at end of file diff --git a/examples/basic_conundrum/main.cpp b/examples/basic_conundrum/main.cpp new file mode 100644 index 0000000..e6bc8cc --- /dev/null +++ b/examples/basic_conundrum/main.cpp @@ -0,0 +1,122 @@ +#include +#include + +#include "DebugCanvas.h" + +TFT_eSPI tft; +DebugCanvas canvas(tft); + +namespace { +uint32_t frameNumber = 0; + +void drawConundrumScreen( + const char* scramble, + const char* hint) +{ + canvas.setScreenName("ConundrumRound"); + canvas.clear(TFT_NAVY); + + TFT_eSprite& ui = canvas.graphics(); + + ui.setTextDatum(TC_DATUM); + ui.setTextColor(TFT_WHITE, TFT_NAVY); + ui.drawString( + "CONUNDRUM", + DebugCanvas::WIDTH / 2, + 10, + 2); + + constexpr int tileWidth = 24; + constexpr int tileHeight = 34; + constexpr int tileGap = 2; + constexpr int totalWidth = + (tileWidth * 9) + (tileGap * 8); + constexpr int startX = + (DebugCanvas::WIDTH - totalWidth) / 2; + constexpr int tileY = 60; + + for (int index = 0; index < 9; ++index) { + const int x = + startX + index * (tileWidth + tileGap); + + ui.fillRoundRect( + x, + tileY, + tileWidth, + tileHeight, + 3, + TFT_WHITE); + + ui.drawRoundRect( + x, + tileY, + tileWidth, + tileHeight, + 3, + TFT_DARKGREY); + + char letter[2] = { + scramble[index], + '\0' + }; + + ui.setTextDatum(MC_DATUM); + ui.setTextColor(TFT_BLACK, TFT_WHITE); + ui.drawString( + letter, + x + tileWidth / 2, + tileY + tileHeight / 2, + 2); + } + + ui.setTextDatum(TC_DATUM); + ui.setTextColor(TFT_YELLOW, TFT_NAVY); + ui.drawString( + "HINT", + DebugCanvas::WIDTH / 2, + 125, + 2); + + ui.setTextColor(TFT_WHITE, TFT_NAVY); + ui.drawCentreString( + hint, + DebugCanvas::WIDTH / 2, + 155, + 2); + + canvas.drawDebugOverlay(++frameNumber); + canvas.present(); +} +} + +void setup() +{ + Serial.begin(115200); + + tft.init(); + tft.setRotation(0); + tft.fillScreen(TFT_BLACK); + + if (!canvas.begin()) { + Serial.println( + "[Fatal] Could not allocate debug framebuffer."); + + tft.setTextColor(TFT_RED, TFT_BLACK); + tft.drawString( + "Framebuffer error", + 10, + 20, + 2); + return; + } + + canvas.printDebugInfo(); + + drawConundrumScreen( + "FASTBREAK", + "The first meal of the day"); +} + +void loop() +{ +} diff --git a/include/DebugCanvas.h b/include/DebugCanvas.h new file mode 100644 index 0000000..b08192b --- /dev/null +++ b/include/DebugCanvas.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +class DebugCanvas { +public: + static constexpr int16_t WIDTH = 240; + static constexpr int16_t HEIGHT = 320; + + explicit DebugCanvas(TFT_eSPI& display); + + bool begin(uint8_t preferredDepth = 16); + void clear(uint32_t color = TFT_BLACK); + void present(); + + TFT_eSprite& graphics(); + const TFT_eSprite& graphics() const; + + bool isReady() const; + int16_t width() const; + int16_t height() const; + uint8_t colorDepth() const; + size_t bufferSizeBytes() const; + + void setScreenName(const char* screenName); + const char* screenName() const; + + void drawDebugOverlay(uint32_t frameNumber); + void printDebugInfo(Stream& output = Serial) const; + bool writeRleFrame(Stream& output = Serial); + +private: + TFT_eSPI& display_; + TFT_eSprite sprite_; + + bool ready_ = false; + int16_t width_ = WIDTH; + int16_t height_ = HEIGHT; + uint8_t colorDepth_ = 0; + const char* screenName_ = "Unknown"; + + bool allocate(uint8_t depth); + size_t rleFrameSize(const void* pixels, size_t pixelCount) const; +}; diff --git a/include/core/game_selection.h b/include/core/game_selection.h new file mode 100644 index 0000000..d9a4f35 --- /dev/null +++ b/include/core/game_selection.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +constexpr uint32_t kNoActiveGameEpoch = 0; + +// Identifies which game is currently active (or Home if idle). +enum class ActiveGameKind : uint8_t { + Home = 0, + Puzzle, + Mastermind, +}; + +// Tracks the currently active game: a monotonically-increasing epoch and kind. +struct ActiveGameClock { + uint32_t epoch; + ActiveGameKind kind; +}; + +// Returns true when an incoming game state packet should be applied locally. +inline bool shouldAcceptGameState(const ActiveGameClock& active, + ActiveGameKind incomingKind, + uint32_t incomingEpoch, + bool terminalState = false) { + if (incomingEpoch != active.epoch) { + return incomingEpoch > active.epoch; + } + return incomingKind == active.kind || + (active.kind == ActiveGameKind::Home && terminalState); +} + +// Returns true when a completed game may safely transition back to the Home screen. +inline bool canReturnCompletedGameHome(const ActiveGameClock& active, + ActiveGameKind completedGame, + bool deliveryPending) { + return active.kind == completedGame && !deliveryPending; +} + +// Returns true when a state packet should be sent; guards against transmitting +// stale or mismatched game state over ESP-NOW. +inline bool shouldSendActiveGameState( + const ActiveGameClock& active, ActiveGameKind stateKind, + uint32_t stateEpoch, bool snapshotMatchesCurrent, + bool terminalDelivery = false) { + const bool activeKind = active.kind == stateKind; + const bool terminalFromHome = + active.kind == ActiveGameKind::Home && terminalDelivery; + return (activeKind || terminalFromHome) && active.epoch == stateEpoch && + snapshotMatchesCurrent; +} + +// Produces the next game epoch (max of all known epochs + 1). +// Returns kNoActiveGameEpoch on overflow. +inline uint32_t nextActiveGameEpoch(uint32_t activeEpoch, + uint32_t puzzleEpoch, + uint32_t mastermindEpoch) { + const uint32_t latest = + std::max(activeEpoch, std::max(puzzleEpoch, mastermindEpoch)); + return latest == UINT32_MAX ? kNoActiveGameEpoch : latest + 1; +} diff --git a/include/core/game_state.h b/include/core/game_state.h new file mode 100644 index 0000000..9b287eb --- /dev/null +++ b/include/core/game_state.h @@ -0,0 +1,5 @@ +#pragma once +struct Player { + char name[24]; + int totalScore; +}; diff --git a/include/core/mastermind_logic.h b/include/core/mastermind_logic.h new file mode 100644 index 0000000..4825bee --- /dev/null +++ b/include/core/mastermind_logic.h @@ -0,0 +1,381 @@ +#pragma once + +#include +#include +#include + +constexpr uint8_t kMastermindCodeLength = 4; +constexpr uint8_t kMastermindColorCount = 6; +constexpr uint8_t kMastermindMaxGuesses = 10; + +// Phase of a Mastermind match — SecretEntry → Guessing → RoundComplete → Exited. +enum class MastermindPhase : uint8_t { + SecretEntry = 1, + Guessing, + RoundComplete, + Exited, +}; + +// A four-peg colour sequence used for both the secret code and each guess. +struct MastermindCode { + uint8_t colors[kMastermindCodeLength]; +}; + +// Feedback for a single guess: exact = right colour + right position, +// colorOnly = right colour but wrong position. +struct MastermindFeedback { + uint8_t exact; + uint8_t colorOnly; +}; + +// A single guess entry combining the submitted code with its computed feedback. +struct MastermindGuess { + MastermindCode code; + MastermindFeedback feedback; +}; + +// Full wire-safe match state for one Mastermind game between two boards. +struct MastermindState { + MastermindGuess guesses[kMastermindMaxGuesses]; + MastermindCode secret; + uint32_t gameId; + uint32_t revision; + uint32_t hostBoardId; + uint32_t guestBoardId; + uint32_t codemakerBoardId; + uint32_t roundWinnerBoardId; + uint8_t hostScore; + uint8_t guestScore; + uint8_t round; + uint8_t guessCount; + MastermindPhase phase; + uint8_t reserved[3]; +}; + +// Returns true when every peg in the code is zero (unset). +inline bool isEmptyMastermindCode(const MastermindCode& code) { + for (uint8_t color : code.colors) { + if (color != 0) { + return false; + } + } + return true; +} + +// Returns true when every peg holds a colour in [1, kMastermindColorCount]. +inline bool isValidMastermindCode(const MastermindCode& code) { + for (uint8_t color : code.colors) { + if (color == 0 || color > kMastermindColorCount) { + return false; + } + } + return true; +} + +// Scores a guess against the secret using per-colour frequency buckets. +inline MastermindFeedback evaluateMastermindGuess( + const MastermindCode& secret, const MastermindCode& guess) { + MastermindFeedback feedback{}; + uint8_t secretCounts[kMastermindColorCount + 1]{}; + uint8_t guessCounts[kMastermindColorCount + 1]{}; + for (uint8_t index = 0; index < kMastermindCodeLength; ++index) { + if (secret.colors[index] == guess.colors[index]) { + ++feedback.exact; + } else { + ++secretCounts[secret.colors[index]]; + ++guessCounts[guess.colors[index]]; + } + } + for (uint8_t color = 1; color <= kMastermindColorCount; ++color) { + feedback.colorOnly += secretCounts[color] < guessCounts[color] + ? secretCounts[color] + : guessCounts[color]; + } + return feedback; +} + +// Returns the board ID of the codebreaker (the player who is NOT the codemaker). +inline uint32_t mastermindCodebreakerBoardId(const MastermindState& state) { + return state.codemakerBoardId == state.hostBoardId ? state.guestBoardId + : state.hostBoardId; +} + +// Creates a fresh MastermindState with the host as initial codemaker. +inline MastermindState makeMastermindMatch(uint32_t hostBoardId, + uint32_t guestBoardId, + uint32_t gameId) { + MastermindState state{}; + state.gameId = gameId; + state.hostBoardId = hostBoardId; + state.guestBoardId = guestBoardId; + state.codemakerBoardId = hostBoardId; + state.round = 1; + state.phase = MastermindPhase::SecretEntry; + return state; +} + +// Returns true when both codes have identical peg colours. +inline bool sameMastermindCode(const MastermindCode& first, + const MastermindCode& second) { + return memcmp(first.colors, second.colors, kMastermindCodeLength) == 0; +} + +// Returns true when both states are byte-for-byte identical. +inline bool sameMastermindState(const MastermindState& first, + const MastermindState& second) { + return memcmp(&first, &second, sizeof(MastermindState)) == 0; +} + +// Deep-validates the state: IDs, phase, role parity, score accounting, +// reserved bytes, guess/feedback consistency, and round-winner correctness. +inline bool isValidMastermindState(const MastermindState& state) { + if (state.gameId == 0 || state.hostBoardId == 0 || + state.guestBoardId == 0 || + state.hostBoardId == state.guestBoardId || + (state.codemakerBoardId != state.hostBoardId && + state.codemakerBoardId != state.guestBoardId) || state.round == 0 || + state.codemakerBoardId != + (state.round % 2 == 1 ? state.hostBoardId + : state.guestBoardId) || + state.guessCount > kMastermindMaxGuesses || + state.phase < MastermindPhase::SecretEntry || + state.phase > MastermindPhase::Exited) { + return false; + } + const uint16_t scoreTotal = + static_cast(state.hostScore) + state.guestScore; + const uint16_t completedRounds = + static_cast(state.round - 1) + + (state.phase == MastermindPhase::RoundComplete ? 1 : 0); + const bool validExitedScore = state.phase == MastermindPhase::Exited && + (scoreTotal == state.round - 1 || scoreTotal == state.round); + if (scoreTotal != completedRounds && !validExitedScore) { + return false; + } + for (uint8_t value : state.reserved) { + if (value != 0) { + return false; + } + } + const MastermindGuess emptyGuess{}; + for (uint8_t index = state.guessCount; + index < kMastermindMaxGuesses; ++index) { + if (memcmp(&state.guesses[index], &emptyGuess, + sizeof(emptyGuess)) != 0) { + return false; + } + } + if (state.phase == MastermindPhase::SecretEntry || + state.phase == MastermindPhase::Exited) { + return state.guessCount == 0 && state.roundWinnerBoardId == 0 && + isEmptyMastermindCode(state.secret); + } + if (!isValidMastermindCode(state.secret) || + (state.guessCount == 0 && + state.phase == MastermindPhase::RoundComplete)) { + return false; + } + for (uint8_t index = 0; index < state.guessCount; ++index) { + if (!isValidMastermindCode(state.guesses[index].code)) { + return false; + } + const MastermindFeedback expected = + evaluateMastermindGuess(state.secret, state.guesses[index].code); + if (state.guesses[index].feedback.exact != expected.exact || + state.guesses[index].feedback.colorOnly != expected.colorOnly) { + return false; + } + } + if (state.phase == MastermindPhase::Guessing) { + return state.guessCount < kMastermindMaxGuesses && + state.roundWinnerBoardId == 0 && + (state.guessCount == 0 || + state.guesses[state.guessCount - 1].feedback.exact < + kMastermindCodeLength); + } + const bool solved = + state.guesses[state.guessCount - 1].feedback.exact == + kMastermindCodeLength; + const uint32_t expectedWinner = + solved ? mastermindCodebreakerBoardId(state) + : state.codemakerBoardId; + return state.roundWinnerBoardId == expectedWinner && + (solved || state.guessCount == kMastermindMaxGuesses); +} + +// Transitions SecretEntry → Guessing; only the codemaker may set the secret. +inline bool submitMastermindSecret(MastermindState& state, + const MastermindCode& secret, + uint32_t actorBoardId) { + if (state.phase != MastermindPhase::SecretEntry || + actorBoardId != state.codemakerBoardId || + !isValidMastermindCode(secret)) { + return false; + } + state.secret = secret; + state.phase = MastermindPhase::Guessing; + ++state.revision; + return true; +} + +// Records a guess with computed feedback; transitions to RoundComplete on +// a correct answer or when the maximum guess count is reached. +inline bool submitMastermindGuess(MastermindState& state, + const MastermindCode& guess, + uint32_t actorBoardId) { + if (state.phase != MastermindPhase::Guessing || + actorBoardId == state.codemakerBoardId || + !isValidMastermindCode(guess) || + state.guessCount >= kMastermindMaxGuesses) { + return false; + } + MastermindGuess& record = state.guesses[state.guessCount++]; + record.code = guess; + record.feedback = evaluateMastermindGuess(state.secret, guess); + ++state.revision; + if (record.feedback.exact == kMastermindCodeLength || + state.guessCount == kMastermindMaxGuesses) { + state.phase = MastermindPhase::RoundComplete; + if (record.feedback.exact == kMastermindCodeLength) { + state.roundWinnerBoardId = actorBoardId; + } else { + state.roundWinnerBoardId = state.codemakerBoardId; + } + if (state.roundWinnerBoardId == state.hostBoardId) { + ++state.hostScore; + } else { + ++state.guestScore; + } + } + return true; +} + +// Clears the round, swaps codemaker/codebreaker roles, and advances to the +// next round's SecretEntry phase. Host-only. +inline bool advanceMastermindRound(MastermindState& state, + uint32_t actorBoardId) { + if (state.phase != MastermindPhase::RoundComplete || + actorBoardId != state.hostBoardId || state.round == UINT8_MAX) { + return false; + } + state.codemakerBoardId = mastermindCodebreakerBoardId(state); + memset(state.guesses, 0, sizeof(state.guesses)); + memset(&state.secret, 0, sizeof(state.secret)); + state.roundWinnerBoardId = 0; + state.guessCount = 0; + ++state.round; + ++state.revision; + state.phase = MastermindPhase::SecretEntry; + return true; +} + +// Transitions to Exited, clearing sensitive round data. Either player may exit. +inline bool exitMastermindMatch(MastermindState& state, + uint32_t actorBoardId) { + if (state.phase == MastermindPhase::Exited || + (actorBoardId != state.hostBoardId && + actorBoardId != state.guestBoardId)) { + return false; + } + memset(state.guesses, 0, sizeof(state.guesses)); + memset(&state.secret, 0, sizeof(state.secret)); + state.roundWinnerBoardId = 0; + state.guessCount = 0; + ++state.revision; + state.phase = MastermindPhase::Exited; + return true; +} + +// Ordering result for comparing two Mastermind state snapshots. +enum class MastermindVersionOrder : uint8_t { Older, Same, Newer }; + +// Compares by epoch (gameId) then revision to determine which state is authoritative. +inline MastermindVersionOrder compareMastermindVersion( + const MastermindState& current, const MastermindState& candidate) { + if (candidate.gameId != current.gameId) { + return candidate.gameId > current.gameId + ? MastermindVersionOrder::Newer + : MastermindVersionOrder::Older; + } + if (candidate.revision != current.revision) { + return candidate.revision > current.revision + ? MastermindVersionOrder::Newer + : MastermindVersionOrder::Older; + } + return MastermindVersionOrder::Same; +} + +// Computes a FNV-1a digest over the entire state for integrity and deduplication. +inline uint32_t mastermindStateDigest(const MastermindState& state) { + uint32_t digest = 2166136261u; + const uint8_t* bytes = reinterpret_cast(&state); + for (size_t index = 0; index < sizeof(state); ++index) { + digest ^= bytes[index]; + digest *= 16777619u; + } + return digest; +} + +// Returns true when the candidate is valid and newer; used to accept incoming +// full-state packets. +inline bool shouldAdoptMastermindState(const MastermindState& current, + const MastermindState& candidate) { + return isValidMastermindState(candidate) && + compareMastermindVersion(current, candidate) == + MastermindVersionOrder::Newer; +} + +// Returns true when the observed state makes a pending delivery obsolete. +inline bool isMastermindDeliverySuperseded( + const MastermindState& pending, const MastermindState& observed) { + return pending.gameId == observed.gameId && + pending.revision < observed.revision; +} + +// Applies an exit signal from a peer; idempotent if already exited. +inline bool applyMastermindExitSignal( + MastermindState& current, const MastermindState& candidate, + uint32_t senderBoardId) { + if (!isValidMastermindState(current) || + !isValidMastermindState(candidate) || + candidate.phase != MastermindPhase::Exited || + current.gameId != candidate.gameId || + current.hostBoardId != candidate.hostBoardId || + current.guestBoardId != candidate.guestBoardId) { + return false; + } + if (current.phase == MastermindPhase::Exited) { + return senderBoardId == current.hostBoardId || + senderBoardId == current.guestBoardId; + } + return exitMastermindMatch(current, senderBoardId); +} + +// Verifies that the candidate is a single legal successor by replaying the +// implied action and comparing the result byte-for-byte. +inline bool isValidMastermindTransition(const MastermindState& current, + const MastermindState& candidate, + uint32_t senderBoardId) { + if (!isValidMastermindState(current) || + !isValidMastermindState(candidate) || + candidate.gameId != current.gameId || + candidate.revision != current.revision + 1) { + return false; + } + MastermindState expected = current; + bool applied = false; + if (candidate.phase == MastermindPhase::Exited) { + applied = exitMastermindMatch(expected, senderBoardId); + } else if (current.phase == MastermindPhase::SecretEntry) { + applied = submitMastermindSecret(expected, candidate.secret, + senderBoardId); + } else if (current.phase == MastermindPhase::Guessing && + current.guessCount < kMastermindMaxGuesses) { + applied = submitMastermindGuess( + expected, candidate.guesses[current.guessCount].code, + senderBoardId); + } else if (current.phase == MastermindPhase::RoundComplete) { + applied = advanceMastermindRound(expected, senderBoardId); + } + return applied && sameMastermindState(expected, candidate); +} diff --git a/include/core/protocol.h b/include/core/protocol.h new file mode 100644 index 0000000..e39aee8 --- /dev/null +++ b/include/core/protocol.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include + +#include "mastermind_logic.h" +#include "puzzle_logic.h" + +constexpr uint32_t kProtocolMagic = 0x46374359; +constexpr uint8_t kProtocolVersion = 7; + +// Returns true when candidate is strictly newer than previous using +// wrapping signed arithmetic (handles sequence-number rollover). +inline bool isSequenceNewer(uint32_t candidate, uint32_t previous) { + return static_cast(candidate - previous) > 0; +} + +// Returns true when candidate matches any entry in the sessions array. +inline bool hasSeenSession(uint32_t candidate, const uint32_t* sessions, + size_t count) { + for (size_t index = 0; index < count; ++index) { + if (sessions[index] == candidate) { + return true; + } + } + return false; +} + +// Returns true when the candidate session ID is acceptable: first contact, +// known session, or unknown session from an offline peer. +inline bool canUsePeerSession(uint32_t current, uint32_t candidate, + bool peerOffline, + const uint32_t* retired, size_t retiredCount) { + return candidate != 0 && + (current == 0 || candidate == current || + (peerOffline && !hasSeenSession(candidate, retired, retiredCount))); +} + +// All ESP-NOW packet types exchanged between CYD boards. +enum class MessageType : uint8_t { + Heartbeat = 1, + ScoreUpdate, + FullState, + RequestState, + PuzzleState, + PuzzleAck, + MastermindState, + MastermindFullState, + MastermindAck, + MastermindRequestState, +}; + +// Fixed-size header prepended to every protocol packet. +// Fixed-size header prepended to every protocol packet. +struct PacketHeader { + uint32_t magic; + uint8_t version; + MessageType type; + uint32_t senderId; + uint32_t sessionId; + uint32_t sequence; +}; + +// Keepalive packet; carries uptime for diagnostics. +struct HeartbeatPacket { + PacketHeader header; + uint32_t uptimeMs; +}; + +// Carries a full PuzzleState snapshot for initial sync or reconciliation. +struct PuzzleStatePacket { + PacketHeader header; + ::PuzzleState state; +}; + +static_assert(sizeof(PuzzleStatePacket) == 68, + "PuzzleStatePacket wire format changed"); + +// The protocol is memcpy'd raw between two identically-compiled ESP32 targets. +// Pin the wire-visible field offsets so any accidental reordering or padding +// change fails at compile time rather than silently corrupting sync. +static_assert(offsetof(::PuzzleState, tiles) == 0, "tiles offset changed"); +static_assert(offsetof(::PuzzleState, columns) == 30, "columns offset changed"); +static_assert(offsetof(::PuzzleState, rows) == 31, "rows offset changed"); +static_assert(offsetof(::PuzzleState, theme) == 32, "theme offset changed"); +static_assert(offsetof(::PuzzleState, phase) == 33, "phase offset changed"); +static_assert(offsetof(::PuzzleState, gameId) == 36, "gameId offset changed"); +static_assert(offsetof(::PuzzleState, revision) == 40, + "revision offset changed"); +static_assert(offsetof(::PuzzleState, turnBoardId) == 44, + "turnBoardId offset changed"); +static_assert(sizeof(::PuzzleState) == 48, "PuzzleState size changed"); +static_assert(offsetof(PacketHeader, senderId) == 8, + "PacketHeader senderId offset changed"); + +// Acknowledgement for a PuzzleState or FullState delivery. +struct PuzzleAckPacket { + PacketHeader header; + uint32_t targetBoardId; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; + MessageType acknowledgedType; + uint8_t reserved[3]; +}; + +// Requests the peer to send a full state snapshot on divergence. +struct StateRequestPacket { + PacketHeader header; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; +}; + +// Carries a full MastermindState snapshot. +struct GameStatePacket { + PacketHeader header; + MastermindState state; +}; + +// Acknowledgement for a MastermindState or MastermindFullState delivery. +struct GameAckPacket { + PacketHeader header; + uint32_t targetBoardId; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; + MessageType acknowledgedType; + uint8_t reserved[3]; +}; + +// Requests the peer to resend its authoritative MastermindState. +struct MastermindStateRequestPacket { + PacketHeader header; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; +}; + +static_assert(sizeof(PacketHeader) == 20, "PacketHeader wire format changed"); +static_assert(sizeof(HeartbeatPacket) == 24, + "HeartbeatPacket wire format changed"); +static_assert(sizeof(PuzzleStatePacket) == 68, + "PuzzleStatePacket wire format changed"); +static_assert(sizeof(PuzzleAckPacket) == 40, + "PuzzleAckPacket wire format changed"); +static_assert(sizeof(StateRequestPacket) == 32, + "StateRequestPacket wire format changed"); +static_assert(sizeof(MastermindState) == 96, + "MastermindState wire format changed"); +static_assert(sizeof(GameStatePacket) == 116, + "GameStatePacket wire format changed"); +static_assert(sizeof(GameAckPacket) == 40, + "GameAckPacket wire format changed"); +static_assert(sizeof(MastermindStateRequestPacket) == 32, + "MastermindStateRequestPacket wire format changed"); \ No newline at end of file diff --git a/include/core/puzzle_logic.h b/include/core/puzzle_logic.h new file mode 100644 index 0000000..6294ff8 --- /dev/null +++ b/include/core/puzzle_logic.h @@ -0,0 +1,478 @@ +#pragma once + +#include +#include + +constexpr uint8_t kPuzzleMaxTiles = 30; + +enum class PuzzleTheme : uint8_t { + Greek = 1, + Planets, +}; + +enum class PuzzlePhase : uint8_t { + Playing = 1, + Exited, +}; + +struct PuzzleSpec { + uint8_t columns; + uint8_t rows; + PuzzleTheme theme; +}; + +struct PuzzleState { + uint8_t tiles[kPuzzleMaxTiles]; + uint8_t columns; + uint8_t rows; + PuzzleTheme theme; + PuzzlePhase phase; + uint32_t gameId; + uint32_t revision; + uint32_t turnBoardId; +}; + +// Returns true when both specs describe the same grid size and theme. +inline bool samePuzzleSpec(const PuzzleSpec& first, const PuzzleSpec& second) { + return first.columns == second.columns && first.rows == second.rows && + first.theme == second.theme; +} + +// Returns true when the spec is one of the two supported puzzle configurations +// (3x3 Planets or 4x3 Greek). +inline bool isSupportedPuzzleSpec(const PuzzleSpec& spec) { + return (spec.columns == 3 && spec.rows == 3 && + spec.theme == PuzzleTheme::Planets) || + (spec.columns == 4 && spec.rows == 3 && + spec.theme == PuzzleTheme::Greek); +} + +// Extracts the PuzzleSpec (dimensions and theme) from a full PuzzleState. +inline PuzzleSpec puzzleSpec(const PuzzleState& state) { + return {state.columns, state.rows, state.theme}; +} + +// Returns the total number of tiles (columns * rows), including the blank. +inline uint8_t puzzleTileCount(const PuzzleState& state) { + return static_cast(state.columns * state.rows); +} + +// Creates a solved puzzle in the goal arrangement. Tiles are numbered 1..(n-1) +// left-to-right, top-to-bottom; the blank occupies the last position. +inline PuzzleState makeInitialPuzzle(uint32_t firstTurnBoardId, + uint32_t gameId, + const PuzzleSpec& spec) { + PuzzleState state{}; + if (!isSupportedPuzzleSpec(spec)) { + return state; + } + state.columns = spec.columns; + state.rows = spec.rows; + state.theme = spec.theme; + state.phase = PuzzlePhase::Playing; + const uint8_t count = puzzleTileCount(state); + for (uint8_t i = 0; i + 1 < count; ++i) { + state.tiles[i] = i + 1; + } + state.gameId = gameId; + state.turnBoardId = firstTurnBoardId; + return state; +} + +// Returns true when the two tile positions are horizontally or vertically +// adjacent (Manhattan distance == 1). +inline bool arePuzzlePositionsAdjacent(const PuzzleState& state, + uint8_t first, uint8_t second) { + const uint8_t count = puzzleTileCount(state); + if (first >= count || second >= count || state.columns == 0) { + return false; + } + const uint8_t firstRow = first / state.columns; + const uint8_t firstColumn = first % state.columns; + const uint8_t secondRow = second / state.columns; + const uint8_t secondColumn = second % state.columns; + const uint8_t rowDistance = firstRow > secondRow ? firstRow - secondRow + : secondRow - firstRow; + const uint8_t columnDistance = firstColumn > secondColumn + ? firstColumn - secondColumn + : secondColumn - firstColumn; + return rowDistance + columnDistance == 1; +} + +// Creates a pseudo-randomly scrambled puzzle derived from gameId and the board +// identities. Guarantees the result is not already solved. +inline PuzzleState makeScrambledPuzzle(uint32_t firstTurnBoardId, + uint32_t gameId, + const PuzzleSpec& spec) { + PuzzleState state = makeInitialPuzzle(firstTurnBoardId, gameId, spec); + if (!isSupportedPuzzleSpec(spec)) { + return state; + } + + const uint8_t count = puzzleTileCount(state); + uint8_t blank = count - 1; + uint8_t previousBlank = kPuzzleMaxTiles; + uint32_t random = gameId ^ firstTurnBoardId ^ + (static_cast(spec.columns) << 24) ^ + (static_cast(spec.rows) << 16) ^ + (static_cast(spec.theme) << 8); + const uint16_t steps = static_cast(count) * 8; + for (uint16_t step = 0; step < steps; ++step) { + uint8_t candidates[4]{}; + uint8_t candidateCount = 0; + for (uint8_t position = 0; position < count; ++position) { + if (position != previousBlank && + arePuzzlePositionsAdjacent(state, position, blank)) { + candidates[candidateCount++] = position; + } + } + random = random * 1664525u + 1013904223u; + if (candidateCount == 0) return state; + const uint8_t next = candidates[random % candidateCount]; + state.tiles[blank] = state.tiles[next]; + state.tiles[next] = 0; + previousBlank = blank; + blank = next; + } + bool solved = blank == count - 1; + for (uint8_t position = 0; solved && position + 1 < count; ++position) { + solved = state.tiles[position] == position + 1; + } + if (solved) { + const uint8_t next = count - 2; + state.tiles[blank] = state.tiles[next]; + state.tiles[next] = 0; + } + return state; +} + +// Returns true when the tile arrangement can be reached from the solved state +// via legal moves (inversion-parity rule). +inline bool isSolvablePuzzleArrangement(const PuzzleState& state) { + const uint8_t count = puzzleTileCount(state); + uint16_t inversions = 0; + for (uint8_t first = 0; first < count; ++first) { + if (state.tiles[first] == 0) { + continue; + } + for (uint8_t second = first + 1; second < count; ++second) { + if (state.tiles[second] != 0 && + state.tiles[first] > state.tiles[second]) { + ++inversions; + } + } + } + if (state.columns % 2 == 1) { + return inversions % 2 == 0; + } + uint8_t blank = count; + for (uint8_t position = 0; position < count; ++position) { + if (state.tiles[position] == 0) { + blank = position; + break; + } + } + if (blank == count) { + return false; + } + const uint8_t blankRowFromBottom = + static_cast(state.rows - blank / state.columns); + return (inversions + blankRowFromBottom) % 2 == 1; +} + +// Returns true when the state has a supported spec, valid tile permutation, +// correct phase, non-zero IDs, and a solvable arrangement. +inline bool isValidPuzzle(const PuzzleState& state) { + const PuzzleSpec spec = puzzleSpec(state); + if (!isSupportedPuzzleSpec(spec) || + (state.phase != PuzzlePhase::Playing && + state.phase != PuzzlePhase::Exited) || + state.gameId == 0 || state.turnBoardId == 0) { + return false; + } + const uint8_t count = puzzleTileCount(state); + bool seen[kPuzzleMaxTiles]{}; + for (uint8_t position = 0; position < count; ++position) { + const uint8_t tile = state.tiles[position]; + if (tile >= count || seen[tile]) { + return false; + } + seen[tile] = true; + } + for (uint8_t position = count; position < kPuzzleMaxTiles; ++position) { + if (state.tiles[position] != 0) { + return false; + } + } + return isSolvablePuzzleArrangement(state); +} + +// Returns the index of the blank tile (value 0), or puzzleTileCount() if not +// found (should never happen in a valid state). +inline uint8_t findBlank(const PuzzleState& state) { + const uint8_t count = puzzleTileCount(state); + for (uint8_t i = 0; i < count; ++i) { + if (state.tiles[i] == 0) { + return i; + } + } + return count; +} + +// Returns true when the tile at the given position is already in its goal slot. +inline bool isTileCorrect(const PuzzleState& state, uint8_t position) { + const uint8_t count = puzzleTileCount(state); + return position + 1 < count && state.tiles[position] == position + 1; +} + +// Returns true when every numbered tile occupies its goal position and the +// blank is in the last slot. +inline bool isPuzzleSolved(const PuzzleState& state) { + if (!isValidPuzzle(state) || state.phase != PuzzlePhase::Playing) { + return false; + } + const uint8_t count = puzzleTileCount(state); + for (uint8_t position = 0; position + 1 < count; ++position) { + if (!isTileCorrect(state, position)) { + return false; + } + } + return state.tiles[count - 1] == 0; +} + +// Returns true when both states represent the exact same puzzle (all fields +// including tiles, revision, and turn ownership). +inline bool isSamePuzzle(const PuzzleState& first, const PuzzleState& second) { + if (!samePuzzleSpec(puzzleSpec(first), puzzleSpec(second)) || + first.phase != second.phase || first.gameId != second.gameId || + first.revision != second.revision || + first.turnBoardId != second.turnBoardId) { + return false; + } + for (uint8_t i = 0; i < kPuzzleMaxTiles; ++i) { + if (first.tiles[i] != second.tiles[i]) { + return false; + } + } + return true; +} + +enum class PuzzleVersionOrder : uint8_t { Older, Same, Newer }; + +// Compares two puzzle states by epoch (gameId) then revision to determine +// which is authoritative. +inline PuzzleVersionOrder comparePuzzleVersion(const PuzzleState& current, + const PuzzleState& candidate) { + if (candidate.gameId != current.gameId) { + return candidate.gameId > current.gameId ? PuzzleVersionOrder::Newer + : PuzzleVersionOrder::Older; + } + if (candidate.revision != current.revision) { + return candidate.revision > current.revision ? PuzzleVersionOrder::Newer + : PuzzleVersionOrder::Older; + } + return PuzzleVersionOrder::Same; +} + +// Computes a FNV-1a digest over all state fields for integrity checks and +// delivery deduplication. +inline uint32_t puzzleStateDigest(const PuzzleState& state) { + uint32_t digest = 2166136261u; + const auto addByte = [&digest](uint8_t value) { + digest ^= value; + digest *= 16777619u; + }; + for (uint8_t tile : state.tiles) { + addByte(tile); + } + addByte(state.columns); + addByte(state.rows); + addByte(static_cast(state.theme)); + addByte(static_cast(state.phase)); + const uint32_t fields[] = {state.gameId, state.revision, + state.turnBoardId}; + for (uint32_t field : fields) { + for (uint8_t shift = 0; shift < 32; shift += 8) { + addByte(static_cast(field >> shift)); + } + } + return digest; +} + +// Returns true when the observed state has superseded a pending delivery, +// indicating the delivery no longer needs to be sent. +inline bool isDeliverySuperseded(const PuzzleState& pending, + const PuzzleState& observed) { + return pending.gameId == observed.gameId && + pending.revision < observed.revision; +} + +enum class ReconciliationAction : uint8_t { + None, + SendFullState, + RequestFullState, +}; + +inline ReconciliationAction decideReconciliation( + const PuzzleState& local, uint32_t remoteGameId, uint32_t remoteRevision, + uint32_t remoteDigest, bool localIsAuthority) { + if (remoteGameId < local.gameId || + (remoteGameId == local.gameId && remoteRevision < local.revision)) { + return ReconciliationAction::SendFullState; + } + if (remoteGameId > local.gameId || + (remoteGameId == local.gameId && remoteRevision > local.revision)) { + return ReconciliationAction::RequestFullState; + } + if (remoteDigest == puzzleStateDigest(local)) { + return ReconciliationAction::None; + } + return localIsAuthority ? ReconciliationAction::SendFullState + : ReconciliationAction::RequestFullState; +} + +inline bool tryPuzzleMove(PuzzleState& state, uint8_t position, + uint32_t actorBoardId, uint32_t nextTurnBoardId) { + if (!isValidPuzzle(state) || state.phase != PuzzlePhase::Playing || + state.turnBoardId != actorBoardId || + actorBoardId == nextTurnBoardId || state.revision == UINT32_MAX || + position >= puzzleTileCount(state) || state.tiles[position] == 0) { + return false; + } + const uint8_t blank = findBlank(state); + if (!arePuzzlePositionsAdjacent(state, position, blank)) { + return false; + } + state.tiles[blank] = state.tiles[position]; + state.tiles[position] = 0; + ++state.revision; + state.turnBoardId = nextTurnBoardId; + return true; +} + +inline bool exitPuzzle(PuzzleState& state, uint32_t actorBoardId, + uint32_t otherBoardId) { + if (!isValidPuzzle(state) || state.phase != PuzzlePhase::Playing || + actorBoardId == 0 || otherBoardId == 0 || + actorBoardId == otherBoardId || state.revision == UINT32_MAX || + (state.turnBoardId != actorBoardId && + state.turnBoardId != otherBoardId)) { + return false; + } + state.phase = PuzzlePhase::Exited; + ++state.revision; + return true; +} + +inline bool applyPuzzleExitSignal(PuzzleState& current, + const PuzzleState& candidate, + uint32_t senderBoardId, + uint32_t recipientBoardId) { + if (!isValidPuzzle(current) || !isValidPuzzle(candidate) || + candidate.phase != PuzzlePhase::Exited || + current.gameId != candidate.gameId || + !samePuzzleSpec(puzzleSpec(current), puzzleSpec(candidate)) || + senderBoardId == 0 || recipientBoardId == 0 || + senderBoardId == recipientBoardId || + (current.turnBoardId != senderBoardId && + current.turnBoardId != recipientBoardId)) { + return false; + } + if (candidate.turnBoardId != senderBoardId && + candidate.turnBoardId != recipientBoardId) { + return false; + } + const PuzzleVersionOrder order = comparePuzzleVersion(current, candidate); + const bool equalConflict = + order == PuzzleVersionOrder::Same && !isSamePuzzle(current, candidate); + if (order == PuzzleVersionOrder::Newer || + (equalConflict && senderBoardId < recipientBoardId)) { + current = candidate; + return true; + } + if (current.phase == PuzzlePhase::Playing) { + return exitPuzzle(current, senderBoardId, recipientBoardId); + } + return true; +} + +inline bool isValidRemoteTransition(const PuzzleState& current, + const PuzzleState& incoming, + uint32_t senderBoardId, + uint32_t recipientBoardId) { + if (!isValidPuzzle(current) || !isValidPuzzle(incoming) || + current.phase != PuzzlePhase::Playing || + incoming.phase != PuzzlePhase::Playing || + !samePuzzleSpec(puzzleSpec(current), puzzleSpec(incoming)) || + incoming.gameId != current.gameId || + current.turnBoardId != senderBoardId || + incoming.turnBoardId != recipientBoardId || + incoming.revision != current.revision + 1) { + return false; + } + + const uint8_t oldBlank = findBlank(current); + const uint8_t newBlank = findBlank(incoming); + if (!arePuzzlePositionsAdjacent(current, oldBlank, newBlank) || + incoming.tiles[oldBlank] != current.tiles[newBlank]) { + return false; + } + + for (uint8_t i = 0; i < kPuzzleMaxTiles; ++i) { + if (i != oldBlank && i != newBlank && + incoming.tiles[i] != current.tiles[i]) { + return false; + } + } + return true; +} + +inline bool isValidPuzzleForParticipants(const PuzzleState& state, + uint32_t firstBoardId, + uint32_t secondBoardId) { + if (!isValidPuzzle(state) || firstBoardId == 0 || secondBoardId == 0 || + firstBoardId == secondBoardId) { + return false; + } + if (state.turnBoardId != firstBoardId && state.turnBoardId != secondBoardId) { + return false; + } + // EXIT is legal from either player regardless of whose move it is, so an + // Exited state deliberately breaks move-parity; its sender/turn validity is + // gated by applyPuzzleExitSignal. Only enforce parity while Playing. + if (state.phase == PuzzlePhase::Exited) { + return state.revision != 0; + } + const uint32_t host = firstBoardId < secondBoardId ? firstBoardId + : secondBoardId; + const uint32_t guest = firstBoardId < secondBoardId ? secondBoardId + : firstBoardId; + const uint32_t expectedTurn = state.revision % 2 == 0 ? host : guest; + return state.turnBoardId == expectedTurn; +} + +inline bool shouldAdoptFullState(const PuzzleState& current, + const PuzzleState& incoming, + uint32_t senderBoardId, + uint32_t recipientBoardId) { + if (!isValidPuzzleForParticipants(incoming, senderBoardId, + recipientBoardId) || + incoming.phase != PuzzlePhase::Playing || + comparePuzzleVersion(current, incoming) != PuzzleVersionOrder::Newer) { + return false; + } + const uint32_t host = senderBoardId < recipientBoardId ? senderBoardId + : recipientBoardId; + if (incoming.gameId != current.gameId) { + return incoming.gameId > current.gameId && senderBoardId == host; + } + if (current.phase != PuzzlePhase::Playing || + !samePuzzleSpec(puzzleSpec(current), puzzleSpec(incoming))) { + return false; + } + if (incoming.revision == current.revision + 1) { + return isValidRemoteTransition(current, incoming, senderBoardId, + recipientBoardId); + } + return senderBoardId == host; +} diff --git a/include/countdown_wire.h b/include/countdown_wire.h new file mode 100644 index 0000000..17050e8 --- /dev/null +++ b/include/countdown_wire.h @@ -0,0 +1,273 @@ +#pragma once + +#include +#include +#include + +// Wire-safe (POD, memcpy-able) synchronized state for the Countdown game mode. +// +// Identity fields +// hostBoardId — the score leader; owns authoritative game state. +// guestBoardId — the non-leader player. +// chooserBoardId — the previous round winner; picks the next round type. +// Initialised to hostBoardId; retained on no-winner rounds. +// hostTerm — incremented on every host-authority transfer; authoritative +// packets with a stale hostTerm are rejected. +// +// Round-specific puzzle content (tiles, letters, conundrum scramble) is +// transmitted via CountdownFullStatePacket, not stored here. + +enum class CountdownWirePhase : uint8_t { + Setup = 1, + InRound, + BetweenRounds, + MatchComplete, + Exited, +}; + +// Sub-phase within InRound. Stored in roundSubPhase; 0 = none. +enum class CountdownRoundSubPhase : uint8_t { + None = 0, + Intro = 1, // 30-second get-ready animation (all round types) + NumPicking = 2, // chooser selects large-number count + NumThinking = 3, // 30-second silent calculation + NumClaimEntry = 4, // private achieved-value entry + NumClaimReveal = 5, // both values revealed simultaneously + NumPresentPlayerA = 6, // first presenter (closer to target) + NumPresentPlayerB = 7, // second presenter + NumResult = 8, + LetPicking = 9, // host draws vowels and consonants + LetThinking = 10, // 30-second solve timer + LetClaimEntry = 11, // private word-length claim + LetClaimReveal = 12, + LetPresentPlayerA = 13, + LetPresentPlayerB = 14, + LetResult = 15, + ConReady = 16, // conundrum intro + ConActive = 17, // circular tiles visible; both players attempt + ConResult = 18, // someone solved it + ConResultNoWinner = 19, // timeout; solution revealed; no points + HostTransfer = 20, // old host sending committed snapshot + HostTransferAck = 21, // new host acknowledged +}; + +struct CountdownWireState { + uint32_t gameId; // offset 0 + uint32_t revision; // offset 4 + uint32_t hostBoardId; // offset 8 + uint32_t guestBoardId; // offset 12 + uint32_t chooserBoardId; // offset 16 (previous round winner) + uint32_t hostTerm; // offset 20 (incremented on authority transfer) + int32_t hostScore; // offset 24 + int32_t guestScore; // offset 28 + uint32_t roundNumber; // offset 32 + CountdownWirePhase phase; // offset 36 + uint8_t roundType; // offset 37 + uint8_t roundSubPhase; // offset 38 (CountdownRoundSubPhase cast) + uint8_t roundConfig; // offset 39 round-specific config (e.g. largeCount for Numbers) +}; + +// Host-authority record — carried in transfer packets; every authoritative +// packet includes hostTerm so delayed old-host packets can be rejected. +struct HostAuthority { + uint32_t hostBoardId; + uint32_t hostTerm; + uint32_t nextSequenceNumber; +}; + +inline bool sameCountdownWireState(const CountdownWireState& first, + const CountdownWireState& second) { + return memcmp(&first, &second, sizeof(CountdownWireState)) == 0; +} + +inline bool isValidCountdownWireState(const CountdownWireState& state) { + if (state.gameId == 0 || state.hostBoardId == 0 || + state.guestBoardId == 0 || + state.hostBoardId == state.guestBoardId || + state.hostTerm == 0 || + (state.chooserBoardId != 0 && + state.chooserBoardId != state.hostBoardId && + state.chooserBoardId != state.guestBoardId) || + state.phase < CountdownWirePhase::Setup || + state.phase > CountdownWirePhase::Exited) { + return false; + } + return true; +} + +inline CountdownWireState makeCountdownMatch(uint32_t hostBoardId, + uint32_t guestBoardId, + uint32_t gameId) { + CountdownWireState state{}; + state.gameId = gameId; + state.revision = 1; + state.hostBoardId = hostBoardId; + state.guestBoardId = guestBoardId; + state.chooserBoardId = hostBoardId; // host chooses the first round + state.hostTerm = 1; + state.roundNumber = 1; + state.phase = CountdownWirePhase::Setup; + return state; +} + +enum class CountdownVersionOrder : uint8_t { Older, Same, Newer }; + +inline CountdownVersionOrder compareCountdownVersion( + const CountdownWireState& current, const CountdownWireState& candidate) { + if (candidate.gameId != current.gameId) { + return candidate.gameId > current.gameId + ? CountdownVersionOrder::Newer + : CountdownVersionOrder::Older; + } + if (candidate.revision != current.revision) { + return candidate.revision > current.revision + ? CountdownVersionOrder::Newer + : CountdownVersionOrder::Older; + } + return CountdownVersionOrder::Same; +} + +inline uint32_t countdownStateDigest(const CountdownWireState& state) { + uint32_t digest = 2166136261u; + const uint8_t* bytes = reinterpret_cast(&state); + for (size_t index = 0; index < sizeof(state); ++index) { + digest ^= bytes[index]; + digest *= 16777619u; + } + return digest; +} + +inline bool shouldAdoptCountdownState(const CountdownWireState& current, + const CountdownWireState& candidate) { + return isValidCountdownWireState(candidate) && + compareCountdownVersion(current, candidate) == + CountdownVersionOrder::Newer; +} + +inline bool isCountdownDeliverySuperseded(const CountdownWireState& pending, + const CountdownWireState& observed) { + return pending.gameId == observed.gameId && + pending.revision < observed.revision; +} + +inline bool exitCountdownMatch(CountdownWireState& state, + uint32_t actorBoardId) { + if (state.phase == CountdownWirePhase::Exited || + (actorBoardId != state.hostBoardId && + actorBoardId != state.guestBoardId)) { + return false; + } + ++state.revision; + state.phase = CountdownWirePhase::Exited; + return true; +} + +inline bool advanceCountdownRound(CountdownWireState& state, + uint32_t actorBoardId) { + if (state.phase == CountdownWirePhase::Exited || + actorBoardId != state.hostBoardId || state.roundNumber == UINT32_MAX) { + return false; + } + ++state.roundNumber; + ++state.revision; + state.phase = CountdownWirePhase::BetweenRounds; + return true; +} + +inline bool selectCountdownRoundType(CountdownWireState& state, + uint32_t actorBoardId, + uint8_t roundType) { + const bool pickablePhase = state.phase == CountdownWirePhase::BetweenRounds || + state.phase == CountdownWirePhase::Setup; + if (!pickablePhase || + actorBoardId != state.chooserBoardId || + roundType < 1 || roundType > 3) { + return false; + } + state.roundType = roundType; + state.roundSubPhase = static_cast(CountdownRoundSubPhase::Intro); + state.phase = CountdownWirePhase::InRound; + ++state.revision; + return true; +} + +// Advance to a new round sub-phase. Host-only. +inline bool advanceCountdownSubPhase(CountdownWireState& state, + uint32_t actorBoardId, + CountdownRoundSubPhase next) { + if (actorBoardId != state.hostBoardId || + state.phase != CountdownWirePhase::InRound) { + return false; + } + state.roundSubPhase = static_cast(next); + ++state.revision; + return true; +} + +// Apply a committed host-transfer snapshot. Both boards increment hostTerm. +inline bool applyCountdownHostTransfer(CountdownWireState& state, + uint32_t newHostBoardId, + uint32_t expectedHostTerm) { + if (state.hostTerm != expectedHostTerm || + (newHostBoardId != state.hostBoardId && + newHostBoardId != state.guestBoardId)) { + return false; + } + if (newHostBoardId != state.hostBoardId) { + // Swap host and guest identity + uint32_t tmp = state.hostBoardId; + state.hostBoardId = state.guestBoardId; + state.guestBoardId = tmp; + int32_t tmpScore = state.hostScore; + state.hostScore = state.guestScore; + state.guestScore = tmpScore; + } + ++state.hostTerm; + ++state.revision; + return true; +} + +inline bool applyCountdownExitSignal(CountdownWireState& current, + const CountdownWireState& candidate, + uint32_t senderBoardId) { + if (!isValidCountdownWireState(current) || + !isValidCountdownWireState(candidate) || + candidate.phase != CountdownWirePhase::Exited || + current.gameId != candidate.gameId || + current.hostBoardId != candidate.hostBoardId || + current.guestBoardId != candidate.guestBoardId) { + return false; + } + if (current.phase == CountdownWirePhase::Exited) { + return senderBoardId == current.hostBoardId || + senderBoardId == current.guestBoardId; + } + return exitCountdownMatch(current, senderBoardId); +} + +static_assert(sizeof(CountdownWireState) == 40, + "CountdownWireState wire format changed"); +static_assert(offsetof(CountdownWireState, gameId) == 0, + "CountdownWireState gameId offset changed"); +static_assert(offsetof(CountdownWireState, revision) == 4, + "CountdownWireState revision offset changed"); +static_assert(offsetof(CountdownWireState, hostBoardId) == 8, + "CountdownWireState hostBoardId offset changed"); +static_assert(offsetof(CountdownWireState, guestBoardId) == 12, + "CountdownWireState guestBoardId offset changed"); +static_assert(offsetof(CountdownWireState, chooserBoardId) == 16, + "CountdownWireState chooserBoardId offset changed"); +static_assert(offsetof(CountdownWireState, hostTerm) == 20, + "CountdownWireState hostTerm offset changed"); +static_assert(offsetof(CountdownWireState, hostScore) == 24, + "CountdownWireState hostScore offset changed"); +static_assert(offsetof(CountdownWireState, guestScore) == 28, + "CountdownWireState guestScore offset changed"); +static_assert(offsetof(CountdownWireState, roundNumber) == 32, + "CountdownWireState roundNumber offset changed"); +static_assert(offsetof(CountdownWireState, phase) == 36, + "CountdownWireState phase offset changed"); +static_assert(offsetof(CountdownWireState, roundType) == 37, + "CountdownWireState roundType offset changed"); +static_assert(offsetof(CountdownWireState, roundSubPhase) == 38, + "CountdownWireState roundSubPhase offset changed"); diff --git a/include/game_selection.h b/include/game_selection.h index 14ded20..dbcccf3 100644 --- a/include/game_selection.h +++ b/include/game_selection.h @@ -9,6 +9,7 @@ enum class ActiveGameKind : uint8_t { Home = 0, Puzzle, Mastermind, + Countdown, }; struct ActiveGameClock { @@ -45,9 +46,10 @@ inline bool shouldSendActiveGameState( } inline uint32_t nextActiveGameEpoch(uint32_t activeEpoch, - uint32_t puzzleEpoch, - uint32_t mastermindEpoch) { + uint32_t puzzleEpoch = 0, + uint32_t mastermindEpoch = 0, + uint32_t countdownEpoch = 0) { const uint32_t latest = - std::max(activeEpoch, std::max(puzzleEpoch, mastermindEpoch)); + std::max(activeEpoch, std::max(puzzleEpoch, std::max(mastermindEpoch, countdownEpoch))); return latest == UINT32_MAX ? kNoActiveGameEpoch : latest + 1; } diff --git a/include/protocol.h b/include/protocol.h index f83fe6e..9d1e137 100644 --- a/include/protocol.h +++ b/include/protocol.h @@ -3,8 +3,11 @@ #include #include +#include "flip7/countdown_engine.hpp" + #include "mastermind_logic.h" #include "puzzle_logic.h" +#include "countdown_wire.h" constexpr uint32_t kProtocolMagic = 0x46374359; constexpr uint8_t kProtocolVersion = 7; @@ -42,6 +45,12 @@ enum class MessageType : uint8_t { MastermindFullState, MastermindAck, MastermindRequestState, + CountdownState, + CountdownFullState, + CountdownAck, + CountdownRequestState, + CountdownAction, // in-round event (PickLetter, PresentStep, etc.) + CountdownPuzzleDescriptor, // host sends versioned seed + checksum }; struct PacketHeader { @@ -121,6 +130,55 @@ struct MastermindStateRequestPacket { uint32_t stateDigest; }; +struct CountdownStatePacket { + PacketHeader header; + CountdownWireState state; +}; + +// Full round state — used for letter sync during picking and reconciliation. +// letters[0..letterCount-1] holds the 9 drawn letters; rest are zero. +struct CountdownFullStatePacket { + PacketHeader header; + CountdownWireState wireState; + char letters[9]; + uint8_t letterCount; + uint8_t reserved[2]; // must be zero +}; + +// In-round event packet — lightweight peer-to-peer action transport. +// actionType is cast to flip7::countdown::CommandType. +// payload encoding is command-specific (see CommandType comments in engine). +struct CountdownActionPacket { + PacketHeader header; + uint8_t actionType; // flip7::countdown::CommandType + uint8_t payload[15]; // command-specific encoding +}; + +// Versioned puzzle descriptor — host sends after generating a round; +// guest verifies checksum before using the seeded content. +struct CountdownPuzzleDescriptorPacket { + PacketHeader header; + uint32_t gameId; + flip7::countdown::PuzzleDescriptor descriptor; +}; + +struct CountdownAckPacket { + PacketHeader header; + uint32_t targetBoardId; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; + MessageType acknowledgedType; + uint8_t reserved[3]; +}; + +struct CountdownStateRequestPacket { + PacketHeader header; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; +}; + static_assert(sizeof(PacketHeader) == 20, "PacketHeader wire format changed"); static_assert(sizeof(HeartbeatPacket) == 24, "HeartbeatPacket wire format changed"); @@ -138,3 +196,15 @@ static_assert(sizeof(GameAckPacket) == 40, "GameAckPacket wire format changed"); static_assert(sizeof(MastermindStateRequestPacket) == 32, "MastermindStateRequestPacket wire format changed"); +static_assert(sizeof(CountdownStatePacket) == 60, + "CountdownStatePacket wire format changed"); +static_assert(sizeof(CountdownFullStatePacket) == 72, + "CountdownFullStatePacket wire format changed"); +static_assert(sizeof(CountdownActionPacket) == 36, + "CountdownActionPacket wire format changed"); +static_assert(sizeof(CountdownPuzzleDescriptorPacket) == 36, + "CountdownPuzzleDescriptorPacket wire format changed"); +static_assert(sizeof(CountdownAckPacket) == 40, + "CountdownAckPacket wire format changed"); +static_assert(sizeof(CountdownStateRequestPacket) == 32, + "CountdownStateRequestPacket wire format changed"); diff --git a/scripts/__pycache__/upload.cpython-314.pyc b/scripts/__pycache__/upload.cpython-314.pyc new file mode 100644 index 0000000..a960c0b Binary files /dev/null and b/scripts/__pycache__/upload.cpython-314.pyc differ diff --git a/scripts/capture_debug_canvas.py b/scripts/capture_debug_canvas.py new file mode 100644 index 0000000..613b0f5 --- /dev/null +++ b/scripts/capture_debug_canvas.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Capture DebugCanvas framebuffers from connected CYDs as PNG files.""" + +import argparse +import binascii +import glob +import struct +import time +import zlib +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import serial + + +def read_exact(connection: serial.Serial, length: int) -> bytes: + data = bytearray() + while len(data) < length: + chunk = connection.read(length - len(data)) + if not chunk: + raise TimeoutError( + f"Timed out after {len(data)} of {length} framebuffer bytes" + ) + data.extend(chunk) + return bytes(data) + + +def decode_rle(payload: bytes, pixel_count: int, depth: int) -> bytes: + rgb = bytearray() + offset = 0 + decoded_pixels = 0 + color_size = 1 if depth == 8 else 2 + + while offset < len(payload): + if offset + 1 + color_size > len(payload): + raise ValueError("Truncated RLE run") + run_length = payload[offset] + offset += 1 + if depth == 8: + value = payload[offset] + offset += 1 + red = ((value >> 5) & 0x07) * 255 // 7 + green = ((value >> 2) & 0x07) * 255 // 7 + blue = (value & 0x03) * 255 // 3 + else: + value = struct.unpack_from(">H", payload, offset)[0] + offset += 2 + red = ((value >> 11) & 0x1F) * 255 // 31 + green = ((value >> 5) & 0x3F) * 255 // 63 + blue = (value & 0x1F) * 255 // 31 + rgb.extend(bytes((red, green, blue)) * run_length) + decoded_pixels += run_length + + if decoded_pixels != pixel_count: + raise ValueError( + f"Decoded {decoded_pixels} pixels; expected {pixel_count}" + ) + return bytes(rgb) + + +def png_chunk(kind: bytes, data: bytes) -> bytes: + checksum = binascii.crc32(kind) + checksum = binascii.crc32(data, checksum) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum) + + +def write_png(path: Path, width: int, height: int, rgb: bytes) -> None: + stride = width * 3 + scanlines = b"".join( + b"\x00" + rgb[row * stride:(row + 1) * stride] + for row in range(height) + ) + signature = b"\x89PNG\r\n\x1a\n" + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + path.write_bytes( + signature + + png_chunk(b"IHDR", header) + + png_chunk(b"IDAT", zlib.compress(scanlines, 9)) + + png_chunk(b"IEND", b"") + ) + + +def capture(port: str, output_dir: Path, timeout: float) -> tuple[Path, str]: + with serial.Serial(port, 115200, timeout=timeout, exclusive=True) as connection: + time.sleep(0.15) + connection.reset_input_buffer() + connection.write(b"S") + connection.flush() + + deadline = time.monotonic() + timeout + header = b"" + while time.monotonic() < deadline: + line = connection.readline() + if line.startswith(b"DCFRAME "): + header = line + break + if line.startswith(b"DCFRAME_ERROR "): + raise RuntimeError(line.decode("utf-8", errors="replace").strip()) + if not header: + raise TimeoutError(f"No DebugCanvas frame header received from {port}") + + parts = header.decode("ascii").strip().split(maxsplit=7) + if len(parts) != 8 or parts[1] != "1": + raise ValueError(f"Unsupported frame header: {header!r}") + width, height, depth, encoded_size = map(int, parts[2:6]) + screen_name = parts[6] if len(parts) == 7 else parts[7] + if depth not in (8, 16): + raise ValueError(f"Unsupported color depth: {depth}") + + payload = read_exact(connection, encoded_size) + rgb = decode_rle(payload, width * height, depth) + + output_path = output_dir / f"cyd-{Path(port).name}.png" + write_png(output_path, width, height, rgb) + return output_path, screen_name + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("ports", nargs="*", help="Serial ports; defaults to both CYDs") + parser.add_argument("--output-dir", type=Path, default=Path("/tmp")) + parser.add_argument("--timeout", type=float, default=10.0) + args = parser.parse_args() + + ports = args.ports or sorted( + glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*") + ) + if not ports: + raise SystemExit("No CYD serial ports found") + args.output_dir.mkdir(parents=True, exist_ok=True) + + with ThreadPoolExecutor(max_workers=len(ports)) as executor: + futures = [ + executor.submit(capture, port, args.output_dir, args.timeout) + for port in ports + ] + for port, future in zip(ports, futures): + path, screen_name = future.result() + print(f"{port}: {screen_name} -> {path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/upload.py b/scripts/upload.py new file mode 100644 index 0000000..751a962 --- /dev/null +++ b/scripts/upload.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Build, upload, and monitor Flip7 CYD firmware. + +Loads ESP-NOW keys from ~/.config/flip7-cyd-scorekeeper/espnow.env, +sets FLIP7_ESPNOW_PMK and FLIP7_ESPNOW_LMK in the environment, then +delegates to PlatformIO. + +Usage: + python3 scripts/upload.py # build + upload to all boards + python3 scripts/upload.py --build # build only (no upload) + python3 scripts/upload.py --monitor # build + upload + monitor last port + python3 scripts/upload.py --monitor-only # open monitor on all detected ports + python3 scripts/upload.py --monitor-only --port /dev/ttyUSB0 # monitor one board + python3 scripts/upload.py --port /dev/ttyUSB0 # upload to a specific port only +""" + +import argparse +import glob +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +ENV_FILE = Path.home() / ".config" / "flip7-cyd-scorekeeper" / "espnow.env" +REQUIRED_KEYS = ("FLIP7_ESPNOW_PMK", "FLIP7_ESPNOW_LMK") + +# Glob patterns for USB serial adapters in priority order. +PORT_PATTERNS = ["/dev/ttyUSB*", "/dev/ttyACM*"] + + +def load_env_file(path: Path) -> dict[str, str]: + """Parse `export KEY=VALUE` lines from a shell env file.""" + if not path.exists(): + print(f"ERROR: ESP-NOW key file not found: {path}", file=sys.stderr) + print( + "Run the one-time provisioning step from the README to create it.", + file=sys.stderr, + ) + sys.exit(1) + + env: dict[str, str] = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):] + if "=" not in line: + continue + key, _, value = line.partition("=") + env[key.strip()] = value.strip() + + for key in REQUIRED_KEYS: + if key not in env: + print(f"ERROR: {key} not found in {path}", file=sys.stderr) + sys.exit(1) + + return env + + +def detect_ports() -> list[str]: + """Return sorted list of all connected USB serial ports.""" + ports: list[str] = [] + for pattern in PORT_PATTERNS: + ports.extend(sorted(glob.glob(pattern))) + return ports + + +def run(cmd: list[str], env: dict[str, str]) -> int: + """Run a subprocess, streaming output, return exit code.""" + merged = {**os.environ, **env} + print(f"\n>>> {' '.join(cmd)}\n", flush=True) + result = subprocess.run(cmd, env=merged) + return result.returncode + + +def build(esp_env: dict[str, str]) -> None: + rc = run(["pio", "run"], esp_env) + if rc != 0: + print(f"\nBuild failed (exit {rc})", file=sys.stderr) + sys.exit(rc) + + +def release_port(port: str) -> None: + """Kill any process holding an exclusive lock on the port, then wait for it to release.""" + try: + result = subprocess.run( + ["fuser", port], capture_output=True, text=True + ) + pids = result.stdout.split() + if not pids: + return + print(f" Releasing {port} from PID(s): {', '.join(pids)}") + for pid in pids: + try: + os.kill(int(pid), signal.SIGTERM) + except (ProcessLookupError, ValueError): + pass + # Wait up to 2 seconds for the lock to drop + for _ in range(20): + time.sleep(0.1) + check = subprocess.run(["fuser", port], capture_output=True, text=True) + if not check.stdout.strip(): + break + except FileNotFoundError: + pass # fuser not available; proceed anyway + + +def upload_to(port: str, esp_env: dict[str, str]) -> bool: + """Upload to a single port. Returns True on success.""" + print(f"\n{'='*60}") + print(f" Uploading to {port}") + print(f"{'='*60}") + release_port(port) + rc = run(["pio", "run", "--target", "upload", "--upload-port", port], esp_env) + if rc != 0: + print(f"\nUpload to {port} failed (exit {rc})", file=sys.stderr) + return False + print(f"\nUpload to {port} complete.") + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build, upload, and monitor CYD firmware") + group = parser.add_mutually_exclusive_group() + group.add_argument("--build", action="store_true", help="Compile only; no upload") + group.add_argument( + "--monitor", action="store_true", + help="Build, upload to all boards, then open serial monitor on the last port", + ) + group.add_argument( + "--monitor-only", action="store_true", + help="Skip build/upload; open serial monitor on the target port(s)", + ) + parser.add_argument( + "--port", metavar="PORT", + help="Target a specific port (e.g. /dev/ttyUSB0)", + ) + args = parser.parse_args() + + project_root = Path(__file__).parent.parent + os.chdir(project_root) + + esp_env = load_env_file(ENV_FILE) + + # --monitor-only: skip build and upload entirely + if args.monitor_only: + port = args.port or _single_detected_port() + print(f"Opening monitor on {port}") + run(["pio", "device", "monitor", "--port", port], esp_env) + return + + build(esp_env) + + if args.build: + print("\nBuild complete.") + return + + # Determine target ports + if args.port: + ports = [args.port] + else: + ports = detect_ports() + if not ports: + print("ERROR: No serial ports found. Is a CYD board plugged in?", + file=sys.stderr) + sys.exit(1) + + print(f"\nFound {len(ports)} board(s): {', '.join(ports)}") + + failed: list[str] = [] + for port in ports: + if not upload_to(port, esp_env): + failed.append(port) + + if failed: + print(f"\nERROR: Upload failed for: {', '.join(failed)}", file=sys.stderr) + sys.exit(1) + + print(f"\nAll {len(ports)} board(s) flashed successfully.") + + if args.monitor: + # Monitor the last port + run(["pio", "device", "monitor", "--port", ports[-1]], esp_env) + + +def _single_detected_port() -> str: + """Return the first detected port, or exit with an error.""" + ports = detect_ports() + if not ports: + print("ERROR: No serial ports found.", file=sys.stderr) + sys.exit(1) + return ports[0] + + +if __name__ == "__main__": + main() diff --git a/src/DebugCanvas.cpp b/src/DebugCanvas.cpp new file mode 100644 index 0000000..43f4a2b --- /dev/null +++ b/src/DebugCanvas.cpp @@ -0,0 +1,305 @@ +#include "DebugCanvas.h" + +#include + +DebugCanvas::DebugCanvas(TFT_eSPI& display) + : display_(display), + sprite_(&display) +{ +} + +bool DebugCanvas::begin(uint8_t preferredDepth) +{ + if (preferredDepth != 8 && preferredDepth != 16) { + preferredDepth = 16; + } + + ready_ = false; + colorDepth_ = 0; + width_ = display_.width(); + height_ = display_.height(); + + if (allocate(preferredDepth)) { + Serial.printf( + "[DebugCanvas] Allocated %dx%d %u-bit framebuffer.\n", + width_, height_, preferredDepth); + return true; + } + + if (preferredDepth == 8) { + Serial.printf( + "[DebugCanvas] ERROR: %dx%d 8-bit framebuffer allocation failed.\n", + width_, height_); + return false; + } + + Serial.printf( + "[DebugCanvas] %u-bit allocation failed; trying 8-bit.\n", + preferredDepth); + + if (allocate(8)) { + Serial.printf( + "[DebugCanvas] Allocated %dx%d 8-bit framebuffer.\n", + width_, height_); + return true; + } + + Serial.println( + "[DebugCanvas] ERROR: framebuffer allocation failed."); + + return false; +} + +bool DebugCanvas::allocate(uint8_t depth) +{ + sprite_.deleteSprite(); + sprite_.setColorDepth(depth); + + void* buffer = sprite_.createSprite(width_, height_); + + if (buffer == nullptr) { + return false; + } + + ready_ = true; + colorDepth_ = depth; + + sprite_.setSwapBytes(true); + sprite_.fillSprite(TFT_BLACK); + + return true; +} + +void DebugCanvas::clear(uint32_t color) +{ + if (!ready_) { + return; + } + + sprite_.fillSprite(color); +} + +void DebugCanvas::present() +{ + if (!ready_) { + return; + } + + const uint32_t startedAt = micros(); + + sprite_.pushSprite(0, 0); + + const uint32_t elapsed = micros() - startedAt; + + Serial.printf( + "[DebugCanvas] Presented '%s' in %lu us\n", + screenName_, + static_cast(elapsed)); +} + +TFT_eSprite& DebugCanvas::graphics() +{ + return sprite_; +} + +const TFT_eSprite& DebugCanvas::graphics() const +{ + return sprite_; +} + +bool DebugCanvas::isReady() const +{ + return ready_; +} + +int16_t DebugCanvas::width() const +{ + return width_; +} + +int16_t DebugCanvas::height() const +{ + return height_; +} + +uint8_t DebugCanvas::colorDepth() const +{ + return colorDepth_; +} + +size_t DebugCanvas::bufferSizeBytes() const +{ + if (!ready_) { + return 0; + } + + const size_t pixelCount = + static_cast(width_) * + static_cast(height_); + + if (colorDepth_ == 16) { + return pixelCount * sizeof(uint16_t); + } + + return pixelCount; +} + +void DebugCanvas::setScreenName(const char* screenName) +{ + screenName_ = + screenName != nullptr ? screenName : "Unknown"; +} + +const char* DebugCanvas::screenName() const +{ + return screenName_; +} + +void DebugCanvas::drawDebugOverlay(uint32_t frameNumber) +{ + if (!ready_) { + return; + } + + constexpr int overlayHeight = 18; + + sprite_.fillRect( + 0, + height_ - overlayHeight, + width_, + overlayHeight, + TFT_BLACK); + + sprite_.drawFastHLine( + 0, + height_ - overlayHeight, + width_, + TFT_DARKGREY); + + sprite_.setTextDatum(ML_DATUM); + sprite_.setTextColor(TFT_GREEN, TFT_BLACK); + + char status[80]; + + snprintf( + status, + sizeof(status), + "%s | F:%lu | H:%uK", + screenName_, + static_cast(frameNumber), + static_cast(ESP.getFreeHeap() / 1024)); + + sprite_.drawString( + status, + 3, + height_ - overlayHeight / 2, + 1); +} + +void DebugCanvas::printDebugInfo(Stream& output) const +{ + output.println("-------- DebugCanvas --------"); + output.printf("Ready: %s\n", ready_ ? "yes" : "no"); + output.printf("Screen: %s\n", screenName_); + output.printf( + "Dimensions: %d x %d\n", + width_, + height_); + output.printf( + "Color depth: %u-bit\n", + colorDepth_); + output.printf( + "Buffer: %u bytes\n", + static_cast(bufferSizeBytes())); + output.printf("Free heap: %u bytes\n", ESP.getFreeHeap()); + output.printf( + "Largest RAM: %u bytes\n", + heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)); + output.println("-----------------------------"); +} + +size_t DebugCanvas::rleFrameSize(const void* pixels, size_t pixelCount) const +{ + if (pixels == nullptr || pixelCount == 0) { + return 0; + } + + size_t runCount = 1; + if (colorDepth_ == 8) { + const auto* values = static_cast(pixels); + uint8_t runLength = 1; + for (size_t index = 1; index < pixelCount; ++index) { + if (values[index] == values[index - 1] && runLength < UINT8_MAX) { + ++runLength; + } else { + ++runCount; + runLength = 1; + } + } + return runCount * 2; + } + + const auto* values = static_cast(pixels); + uint8_t runLength = 1; + for (size_t index = 1; index < pixelCount; ++index) { + if (values[index] == values[index - 1] && runLength < UINT8_MAX) { + ++runLength; + } else { + ++runCount; + runLength = 1; + } + } + return runCount * 3; +} + +bool DebugCanvas::writeRleFrame(Stream& output) +{ + if (!ready_) { + output.println("DCFRAME_ERROR canvas-not-ready"); + return false; + } + + const size_t pixelCount = + static_cast(width_) * static_cast(height_); + const void* pixels = sprite_.getPointer(); + const size_t encodedSize = rleFrameSize(pixels, pixelCount); + if (pixels == nullptr || encodedSize == 0) { + output.println("DCFRAME_ERROR framebuffer-unavailable"); + return false; + } + + output.printf("DCFRAME 1 %d %d %u %u %s\n", + width_, height_, colorDepth_, + static_cast(encodedSize), screenName_); + + if (colorDepth_ == 8) { + const auto* values = static_cast(pixels); + for (size_t index = 0; index < pixelCount;) { + uint8_t runLength = 1; + while (index + runLength < pixelCount && + values[index + runLength] == values[index] && + runLength < UINT8_MAX) { + ++runLength; + } + output.write(runLength); + output.write(values[index]); + index += runLength; + } + } else { + const auto* values = static_cast(pixels); + for (size_t index = 0; index < pixelCount;) { + uint8_t runLength = 1; + while (index + runLength < pixelCount && + values[index + runLength] == values[index] && + runLength < UINT8_MAX) { + ++runLength; + } + output.write(runLength); + output.write(reinterpret_cast(&values[index]), + sizeof(values[index])); + index += runLength; + } + } + output.flush(); + return true; +} diff --git a/src/main.cpp b/src/main.cpp index 8f6244a..dad4b52 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include "generated_peer_keys.h" #include "protocol.h" #include "puzzle_logic.h" +#include "flip7/countdown_engine.hpp" namespace { constexpr uint8_t kBacklightPin = 21; @@ -52,6 +53,44 @@ constexpr int16_t kExitX = 271; constexpr int16_t kExitY = 3; constexpr int16_t kExitWidth = 46; constexpr int16_t kExitHeight = 26; +constexpr int16_t kCountdownNewRoundX = 110; +constexpr int16_t kCountdownNewRoundY = 175; +constexpr int16_t kCountdownNewRoundWidth = 100; +constexpr int16_t kCountdownNewRoundHeight = 34; +constexpr int16_t kCountdownRoundTypeY = 90; +constexpr int16_t kCountdownRoundTypeWidth = 90; +constexpr int16_t kCountdownRoundTypeHeight = 60; +constexpr int16_t kCountdownNumbersX = 5; +constexpr int16_t kCountdownLettersX = 115; +constexpr int16_t kCountdownConundrumX = 225; + +// In-round layout constants +constexpr int16_t kCdTimerCX = 280; // arc centre x (top-right corner) +constexpr int16_t kCdTimerCY = 26; +constexpr int16_t kCdTimerR = 22; +constexpr int16_t kCdTileY = 48; // tile strip top-left y +constexpr int16_t kCdTileH = 34; +constexpr int16_t kCdTileW = 46; +constexpr int16_t kCdTileGap = 4; +// Numpad (4 rows x 3 cols) — bottom half of screen +constexpr int16_t kCdPadX = 48; // left edge of pad +constexpr int16_t kCdPadY = 105; +constexpr int16_t kCdPadW = 70; +constexpr int16_t kCdPadH = 32; +constexpr int16_t kCdPadGap = 4; +// Operation buttons row +constexpr int16_t kCdOpY = 185; +constexpr int16_t kCdOpW = 58; +constexpr int16_t kCdOpH = 30; +// Large-count picker (0-4) for Numbers picking phase +constexpr int16_t kCdLcY = 120; +constexpr int16_t kCdLcW = 50; +constexpr int16_t kCdLcH = 44; +// Admin FORCE-END button (host only, stalemate fallback) +constexpr int16_t kCdForceEndX = 230; +constexpr int16_t kCdForceEndY = 210; +constexpr int16_t kCdForceEndW = 80; +constexpr int16_t kCdForceEndH = 24; struct PuzzleLayout { int16_t x; @@ -72,7 +111,7 @@ portMUX_TYPE linkMux = portMUX_INITIALIZER_UNLOCKED; portMUX_TYPE gameMux = portMUX_INITIALIZER_UNLOCKED; SemaphoreHandle_t protocolMutex = nullptr; -enum class ScreenMode : uint8_t { Home, Puzzle, Complete, Mastermind }; +enum class ScreenMode : uint8_t { Home, Puzzle, Complete, Mastermind, Countdown }; struct LinkState { uint32_t sent; @@ -83,8 +122,8 @@ struct LinkState { uint32_t peerSessionId; uint32_t retiredSessions[kRetiredSessionCapacity]; size_t retiredSessionCount; - uint32_t peerSequences[11]; - bool sequenceSeen[11]; + uint32_t peerSequences[15]; + bool sequenceSeen[15]; uint8_t peerAddress[6]; }; @@ -120,6 +159,22 @@ struct MastermindPendingAck { MessageType acknowledgedType; }; +struct CountdownPendingDelivery { + bool active; + MessageType type; + CountdownWireState state; + uint32_t lastSentMs; +}; + +struct CountdownPendingAck { + bool active; + uint32_t targetBoardId; + uint32_t gameId; + uint32_t revision; + uint32_t stateDigest; + MessageType acknowledgedType; +}; + LinkState linkState{}; PuzzleState puzzleState{}; PendingDelivery pendingDelivery{}; @@ -129,6 +184,10 @@ MastermindPendingDelivery mastermindPendingDelivery{}; MastermindPendingAck mastermindPendingAck{}; MastermindCode draftCode{{1, 1, 1, 1}}; MastermindCode lastSubmittedGuess{{1, 1, 1, 1}}; +flip7::countdown::CountdownMatchEngine countdownEngine(0, 0, 0); +CountdownWireState countdownState{}; +CountdownPendingDelivery countdownPendingDelivery{}; +CountdownPendingAck countdownPendingAck{}; ScreenMode screenMode = ScreenMode::Home; ActiveGameClock activeGame{kNoActiveGameEpoch, ActiveGameKind::Home}; uint32_t boardId = 0; @@ -161,6 +220,44 @@ bool mastermindStateReady = false; bool mastermindRequestStateSoon = false; bool mastermindReconciliationPending = false; bool sendMastermindFullStateSoon = false; +bool countdownStateReady = false; +bool countdownRequestStateSoon = false; +bool countdownReconciliationPending = false; +bool sendCountdownFullStateSoon = false; + +// Per-round UI state — local only, not transmitted. +struct NumUiState { + int32_t draftValue{0}; // numpad entry during ClaimEntry + bool claimSent{false}; // claim packet delivered to host + uint8_t largeCount{2}; // large-number count chosen in NumPicking +}; +NumUiState numUi{}; + +struct LetUiState { + uint8_t claimLength{0}; // digit claim (1-9) + bool claimSent{false}; + char word[10]{}; // word being built from tiles + uint8_t wordLen{0}; + bool presentationSent{false}; + bool verificationSent{false}; + bool verificationAnswer{false}; // YES=true / NO=false + bool showConsonants{false}; // toggle in LetPicking view + bool presenterIsHost{true}; // which board presents first +}; +LetUiState letUi{}; + +struct ConUiState { + uint8_t circleOrder[9]{0,1,2,3,4,5,6,7,8}; // display permutation + uint8_t selected[9]{}; // circleOrder indices tapped, in order + uint8_t selectedCount{0}; + bool showGoodTry{false}; + uint32_t goodTryMs{0}; +}; +ConUiState conUi{}; + +uint32_t roundPhaseStartMs = 0; // millis() when current sub-phase began +uint32_t lastAnimFrameMs = 0; // last millis() a timed animation redrawed +constexpr uint32_t kAnimFrameIntervalMs = 100; // ~10 fps for the arc timer bool touchWasDown = false; bool lastOnline = false; uint8_t selectedPeg = 0; @@ -266,7 +363,7 @@ bool matchesBoundPeer(const uint8_t* address, uint32_t senderId) { bool acceptPeerSequence(MessageType type, uint32_t sessionId, uint32_t sequence) { const uint8_t index = static_cast(type); - if (index >= 11) { + if (index >= 15) { return false; } portENTER_CRITICAL(&linkMux); @@ -486,34 +583,38 @@ void renderHome(bool online, bool deliveryPending) { if (localIsHost() && online) { display.setTextColor(TFT_CYAN, TFT_NAVY); display.drawString("Choose a synchronized game", display.width() / 2, - 61, 2); + 55, 2); - // Slide puzzles row - display.fillRoundRect(10, 82, 145, 89, 10, TFT_PURPLE); - display.drawRoundRect(10, 82, 145, 89, 10, TFT_WHITE); + // Row 1: Slide puzzles + display.fillRoundRect(10, 75, 145, 65, 8, TFT_PURPLE); + display.drawRoundRect(10, 75, 145, 65, 8, TFT_WHITE); display.setTextColor(TFT_WHITE, TFT_PURPLE); - display.drawString("PLANETS", 82, 111, 4); - display.drawString("8 PIECES / 3x3", 82, 147, 2); + display.drawString("PLANETS", 82, 95, 4); + display.drawString("8 PIECES / 3x3", 82, 122, 2); - display.fillRoundRect(165, 82, 145, 89, 10, TFT_DARKGREEN); - display.drawRoundRect(165, 82, 145, 89, 10, TFT_WHITE); + display.fillRoundRect(165, 75, 145, 65, 8, TFT_DARKGREEN); + display.drawRoundRect(165, 75, 145, 65, 8, TFT_WHITE); display.setTextColor(TFT_WHITE, TFT_DARKGREEN); - display.drawString("GREEK", 237, 111, 4); - display.drawString("11 PIECES / 4x3", 237, 147, 2); + display.drawString("GREEK", 237, 95, 4); + display.drawString("11 PIECES / 4x3", 237, 122, 2); - // Mastermind row - display.fillRoundRect(45, 185, 230, 55, 10, TFT_MAROON); - display.drawRoundRect(45, 185, 230, 55, 10, TFT_WHITE); + // Row 2: Mastermind & Countdown + display.fillRoundRect(10, 150, 145, 65, 8, TFT_MAROON); + display.drawRoundRect(10, 150, 145, 65, 8, TFT_WHITE); display.setTextColor(TFT_WHITE, TFT_MAROON); - display.drawString("MASTERMIND", display.width() / 2, 212, 4); + display.drawString("MASTERMIND", 82, 170, 4); + display.drawString("CODE BREAKER", 82, 197, 2); + + display.fillRoundRect(165, 150, 145, 65, 8, TFT_BLUE); + display.drawRoundRect(165, 150, 145, 65, 8, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_BLUE); + display.drawString("COUNTDOWN", 237, 170, 4); + display.drawString("NUM/LET/CON", 237, 197, 2); } else { display.setTextColor(online ? TFT_LIGHTGREY : TFT_ORANGE, TFT_NAVY); display.drawString(online ? "WAITING FOR HOST" : "CONNECT PEER", display.width() / 2, 125, 2); } - display.setTextColor(TFT_LIGHTGREY, TFT_NAVY); - display.drawString("Locked pieces lose their background", - display.width() / 2, 207, 2); } void renderPuzzle(bool online, const PuzzleState& game, @@ -722,20 +823,1164 @@ void renderMastermind(bool online, const MastermindState& state, display.drawString("GUESS", 257, 204, 4); } +// --------------------------------------------------------------------------- +// Countdown render helpers +// --------------------------------------------------------------------------- + +// Shared header row: "ROUND N | HOST XX : GUEST XX | EXIT" +static void renderCountdownRoundHeader(const CountdownWireState& state, + bool online) { + display.fillRect(0, 0, 320, 36, TFT_NAVY); + display.setTextDatum(ML_DATUM); + display.setTextColor(TFT_LIGHTGREY, TFT_NAVY); + char rlabel[20]; + snprintf(rlabel, sizeof(rlabel), "RD %lu", + static_cast(state.roundNumber)); + display.drawString(rlabel, 6, 18, 2); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, TFT_NAVY); + char scores[28]; + snprintf(scores, sizeof(scores), "HOST %d : %d GUEST", + state.hostScore, state.guestScore); + display.drawString(scores, 155, 18, 2); + + display.fillRoundRect(kExitX, kExitY, kExitWidth, kExitHeight, 5, + online ? TFT_RED : TFT_ORANGE); + display.drawRoundRect(kExitX, kExitY, kExitWidth, kExitHeight, 5, + TFT_WHITE); + display.setTextColor(TFT_WHITE, online ? TFT_RED : TFT_ORANGE); + display.drawString("EXIT", kExitX + kExitWidth / 2, + kExitY + kExitHeight / 2, 2); +} + +// Silent visual timer \u2014 procedural arc fallback (production will use MJPEG). +// Draws a ring from full to empty as elapsedMs grows toward durationMs. +static void renderCountdownAnimation(int16_t cx, int16_t cy, int16_t r, + uint32_t elapsedMs, uint32_t durationMs) { + // Background ring + display.drawCircle(cx, cy, r, TFT_DARKGREY); + display.drawCircle(cx, cy, r - 1, TFT_DARKGREY); + + float fraction = 1.0f - static_cast(elapsedMs) / + static_cast(durationMs); + if (fraction < 0.0f) fraction = 0.0f; + if (fraction > 1.0f) fraction = 1.0f; + + // Draw coloured arc (yellow > green; red when <20%) + uint16_t colour = (fraction > 0.2f) ? TFT_YELLOW : TFT_RED; + int segments = static_cast(fraction * 60.0f); // 60 = full circle + for (int i = 0; i < segments; ++i) { + float angle = static_cast(i) * 6.0f - 90.0f; // degrees + float rad = angle * 3.14159f / 180.0f; + int16_t px = cx + static_cast(r * cosf(rad)); + int16_t py = cy + static_cast(r * sinf(rad)); + display.drawPixel(px, py, colour); + px = cx + static_cast((r - 1) * cosf(rad)); + py = cy + static_cast((r - 1) * sinf(rad)); + display.drawPixel(px, py, colour); + } + + // Remaining seconds label inside the ring + uint32_t secsLeft = (durationMs > elapsedMs) + ? (durationMs - elapsedMs + 999) / 1000 + : 0; + display.setTextDatum(MC_DATUM); + display.setTextColor(colour, TFT_NAVY); + display.drawNumber(static_cast(secsLeft), cx, cy, 2); +} + +// Draw the 6 Numbers tiles in a horizontal strip. +static void renderCountdownTileStrip(const flip7::countdown::NumbersRoundProjection& proj, + uint16_t usedMask = 0) { + for (uint8_t i = 0; i < proj.tileCount; ++i) { + int16_t tx = static_cast(kCdTileGap + + i * (kCdTileW + kCdTileGap)); + bool used = (usedMask >> i) & 1u; + uint16_t bg = used ? TFT_DARKGREY : TFT_DARKCYAN; + display.fillRoundRect(tx, kCdTileY, kCdTileW, kCdTileH, 5, bg); + display.drawRoundRect(tx, kCdTileY, kCdTileW, kCdTileH, 5, TFT_WHITE); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, bg); + display.drawNumber(proj.tiles[i], tx + kCdTileW / 2, + kCdTileY + kCdTileH / 2, 2); + } +} + +// Draw 3x4 numpad for claim entry. Returns nothing; touch handler checks bounds. +static void renderCountdownNumpad(int32_t draftValue, bool submitted) { + // Digit grid: [7][8][9] / [4][5][6] / [1][2][3] / [0][CLR][SUBMIT] + const int digits[4][3] = {{7,8,9},{4,5,6},{1,2,3},{0,-1,-2}}; + for (int row = 0; row < 4; ++row) { + for (int col = 0; col < 3; ++col) { + int16_t bx = static_cast( + kCdPadX + col * (kCdPadW + kCdPadGap)); + int16_t by = static_cast( + kCdPadY + row * (kCdPadH + kCdPadGap)); + int d = digits[row][col]; + uint16_t bg = TFT_DARKGREY; + const char* label = ""; + char dbuf[4]; + if (d >= 0) { + bg = TFT_DARKCYAN; + snprintf(dbuf, sizeof(dbuf), "%d", d); + label = dbuf; + } else if (d == -1) { + bg = TFT_MAROON; + label = "CLR"; + } else { // SUBMIT + bg = submitted ? TFT_DARKGREY : TFT_DARKGREEN; + label = "OK"; + } + display.fillRoundRect(bx, by, kCdPadW, kCdPadH, 5, bg); + display.drawRoundRect(bx, by, kCdPadW, kCdPadH, 5, TFT_WHITE); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, bg); + display.drawString(label, bx + kCdPadW / 2, by + kCdPadH / 2, 2); + } + } + // Draft value display above numpad + display.fillRect(kCdPadX, kCdPadY - 36, 3 * kCdPadW + 2 * kCdPadGap, 32, + TFT_BLACK); + display.drawRect(kCdPadX, kCdPadY - 36, 3 * kCdPadW + 2 * kCdPadGap, 32, + TFT_WHITE); + display.setTextDatum(MR_DATUM); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + char dvbuf[8]; + snprintf(dvbuf, sizeof(dvbuf), "%ld", static_cast(draftValue)); + display.drawString(dvbuf, + kCdPadX + 3 * kCdPadW + 2 * kCdPadGap - 4, + kCdPadY - 20, 4); +} + +// --------------------------------------------------------------------------- +// Round intro screen (Intro sub-phase) \u2014 all round types +// --------------------------------------------------------------------------- +static void renderCountdownIntro(const CountdownWireState& state, + uint32_t elapsedMs, bool firstFrame) { + if (firstFrame) { + display.fillScreen(TFT_BLACK); + display.setTextDatum(MC_DATUM); + + const char* title = "COUNTDOWN"; + uint16_t titleColour = TFT_WHITE; + if (state.roundType == static_cast(flip7::countdown::RoundType::Numbers)) { + title = "NUMBERS ROUND"; + titleColour = TFT_CYAN; + } else if (state.roundType == static_cast(flip7::countdown::RoundType::Letters)) { + title = "LETTERS ROUND"; + titleColour = TFT_YELLOW; + } else if (state.roundType == static_cast(flip7::countdown::RoundType::Conundrum)) { + title = "CONUNDRUM ROUND"; + titleColour = TFT_MAGENTA; + } + display.setTextColor(titleColour, TFT_BLACK); + display.drawString(title, 160, 75, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Get ready...", 160, 130, 2); + } + // Always update the timer circle region only + display.fillCircle(160, 175, 33, TFT_BLACK); + renderCountdownAnimation(160, 175, 30, elapsedMs, 5000); +} + +// --------------------------------------------------------------------------- +// Numbers sub-phase renders +// --------------------------------------------------------------------------- + +static void renderCountdownNumPicking(const CountdownWireState& state, + uint8_t selectedLarge) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("HOW MANY LARGE NUMBERS?", 160, 55, 2); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Large: 25 50 75 100 Small: 1-10", 160, 75, 2); + + // 5 buttons: 0 1 2 3 4 + for (uint8_t i = 0; i <= 4; ++i) { + int16_t bx = static_cast(16 + i * 58); + uint16_t bg = (i == selectedLarge) ? TFT_CYAN : TFT_DARKGREY; + uint16_t fg = (i == selectedLarge) ? TFT_BLACK : TFT_WHITE; + display.fillRoundRect(bx, kCdLcY, kCdLcW, kCdLcH, 8, bg); + display.drawRoundRect(bx, kCdLcY, kCdLcW, kCdLcH, 8, TFT_WHITE); + display.setTextColor(fg, bg); + display.drawNumber(i, bx + kCdLcW / 2, kCdLcY + kCdLcH / 2, 4); + } + + if (boardId == state.chooserBoardId) { + display.fillRoundRect(95, 185, 130, 36, 8, TFT_DARKGREEN); + display.drawRoundRect(95, 185, 130, 36, 8, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_DARKGREEN); + display.drawString("CONFIRM", 160, 203, 4); + } else { + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Waiting for chooser...", 160, 200, 2); + } +} + +static void renderCountdownNumThinking(const CountdownWireState& state, + const flip7::countdown::NumbersRoundProjection& proj, + uint32_t elapsedMs, bool firstFrame) { + if (firstFrame) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderCountdownTileStrip(proj); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("TARGET", 130, 100, 2); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawNumber(static_cast(proj.target), 130, 135, 6); + + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Calculate on paper", 130, 190, 2); + } + // Only update the timer circle region to avoid full-screen flicker + display.fillCircle(kCdTimerCX, kCdTimerCY, kCdTimerR + 3, TFT_BLACK); + renderCountdownAnimation(kCdTimerCX, kCdTimerCY, kCdTimerR, + elapsedMs, 30000); +} + +static void renderCountdownNumClaimEntry(const CountdownWireState& state, + const flip7::countdown::NumbersRoundProjection& proj, + int32_t draftValue, bool claimSent) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderCountdownTileStrip(proj); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("TARGET", 75, 100, 2); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawNumber(static_cast(proj.target), 75, 130, 4); + + if (claimSent) { + display.setTextColor(TFT_GREEN, TFT_BLACK); + display.drawString("Claim sent!", 75, 165, 2); + display.drawString("Waiting for opponent...", 75, 185, 2); + } else { + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Your answer:", 75, 100 + 3, 2); + renderCountdownNumpad(draftValue, claimSent); + } +} + +static void renderCountdownNumClaimReveal(const CountdownWireState& state, + const flip7::countdown::NumbersRoundProjection& proj, + int32_t hostClaim, int32_t guestClaim) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("CLAIMS REVEALED", 160, 52, 4); + + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawString("TARGET", 160, 90, 2); + display.drawNumber(static_cast(proj.target), 160, 115, 4); + + display.setTextColor(TFT_YELLOW, TFT_BLACK); + char hbuf[20], gbuf[20]; + snprintf(hbuf, sizeof(hbuf), "HOST: %ld", static_cast(hostClaim)); + snprintf(gbuf, sizeof(gbuf), "GUEST: %ld", static_cast(guestClaim)); + display.drawString(hbuf, 160, 155, 4); + display.drawString(gbuf, 160, 190, 4); +} + +static void renderCountdownNumPresenting(const CountdownWireState& state, + const flip7::countdown::NumbersRoundProjection& proj, + bool isPresenter) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderCountdownTileStrip(proj); + + display.setTextDatum(MC_DATUM); + + if (!isPresenter) { + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Opponent is presenting...", 160, 130, 2); + return; + } + + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("TARGET", 160, 96, 2); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawNumber(static_cast(proj.target), 160, 120, 4); + + // Operation buttons + const char* ops[4] = {"+", "-", "x", "/"}; + for (int i = 0; i < 4; ++i) { + int16_t bx = static_cast(6 + i * (kCdOpW + 6)); + display.fillRoundRect(bx, kCdOpY, kCdOpW, kCdOpH, 6, TFT_DARKGREY); + display.drawRoundRect(bx, kCdOpY, kCdOpW, kCdOpH, 6, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_DARKGREY); + display.drawString(ops[i], bx + kCdOpW / 2, kCdOpY + kCdOpH / 2, 4); + } + + // UNDO, END buttons + display.fillRoundRect(256, kCdOpY, 58, kCdOpH, 6, TFT_MAROON); + display.setTextColor(TFT_WHITE, TFT_MAROON); + display.drawString("UNDO", 285, kCdOpY + kCdOpH / 2, 2); + + display.fillRoundRect(256, kCdOpY + kCdOpH + 6, 58, kCdOpH, 6, + TFT_DARKGREEN); + display.setTextColor(TFT_WHITE, TFT_DARKGREEN); + display.drawString("END", 285, kCdOpY + kCdOpH + 6 + kCdOpH / 2, 2); +} + +// --------------------------------------------------------------------------- +// BetweenRounds scoreboard +// --------------------------------------------------------------------------- +static void renderCountdownBetweenRounds(bool online, + const CountdownWireState& state) { + display.fillScreen(TFT_NAVY); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, TFT_NAVY); + renderExitButton(online); + + display.drawString("CHOOSE NEXT ROUND", 145, 20, 4); + + // Show both roles when they differ + bool isChooser = (boardId == state.chooserBoardId); + bool isHost = (boardId == state.hostBoardId); + display.setTextColor(TFT_YELLOW, TFT_NAVY); + if (isChooser) { + display.drawString("YOUR TURN TO CHOOSE", 160, 55, 2); + } else if (isHost) { + display.drawString("WAITING FOR PEER TO CHOOSE", 160, 55, 2); + } else { + display.drawString("WAITING FOR CHOOSER", 160, 55, 2); + } + + if (isChooser) { + // NUMBERS button + display.fillRoundRect(kCountdownNumbersX, kCountdownRoundTypeY, + kCountdownRoundTypeWidth, kCountdownRoundTypeHeight, + 8, TFT_DARKCYAN); + display.drawRoundRect(kCountdownNumbersX, kCountdownRoundTypeY, + kCountdownRoundTypeWidth, kCountdownRoundTypeHeight, + 8, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_DARKCYAN); + display.drawString("NUMBERS", + kCountdownNumbersX + kCountdownRoundTypeWidth / 2, + kCountdownRoundTypeY + kCountdownRoundTypeHeight / 2, + 2); + // LETTERS button + display.fillRoundRect(kCountdownLettersX, kCountdownRoundTypeY, + kCountdownRoundTypeWidth, kCountdownRoundTypeHeight, + 8, TFT_YELLOW); + display.drawRoundRect(kCountdownLettersX, kCountdownRoundTypeY, + kCountdownRoundTypeWidth, kCountdownRoundTypeHeight, + 8, TFT_WHITE); + display.setTextColor(TFT_BLACK, TFT_YELLOW); + display.drawString("LETTERS", + kCountdownLettersX + kCountdownRoundTypeWidth / 2, + kCountdownRoundTypeY + kCountdownRoundTypeHeight / 2, + 2); + // CONUNDRUM button + display.fillRoundRect(kCountdownConundrumX, kCountdownRoundTypeY, + kCountdownRoundTypeWidth, kCountdownRoundTypeHeight, + 8, TFT_MAGENTA); + display.drawRoundRect(kCountdownConundrumX, kCountdownRoundTypeY, + kCountdownRoundTypeWidth, kCountdownRoundTypeHeight, + 8, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_MAGENTA); + display.drawString("CONUNDRUM", + kCountdownConundrumX + kCountdownRoundTypeWidth / 2, + kCountdownRoundTypeY + kCountdownRoundTypeHeight / 2, + 2); + } + + // Scores + round number + display.setTextColor(TFT_WHITE, TFT_NAVY); + char hostLabel[20], guestLabel[20]; + snprintf(hostLabel, sizeof(hostLabel), "HOST %d", state.hostScore); + snprintf(guestLabel, sizeof(guestLabel), "GUEST %d", state.guestScore); + display.drawString(hostLabel, 80, 185, 4); + display.drawString(guestLabel, 240, 185, 4); + + char roundLabel[24]; + snprintf(roundLabel, sizeof(roundLabel), "Round %lu", + static_cast(state.roundNumber)); + display.drawString(roundLabel, 160, 220, 2); +} + +// --------------------------------------------------------------------------- +// Main renderCountdown dispatcher +// --------------------------------------------------------------------------- +// Forward declaration — renderCountdownLetConPhases is defined after the helpers. +static void renderCountdownLetConPhases( + bool online, const CountdownWireState& state, + CountdownRoundSubPhase subPhase, uint32_t elapsed); + +void renderCountdown(bool online, const CountdownWireState& state) { + using SubPhase = CountdownRoundSubPhase; + auto subPhase = static_cast(state.roundSubPhase); + uint32_t now = millis(); + uint32_t elapsed = (now >= roundPhaseStartMs) + ? now - roundPhaseStartMs + : 0; + + if (state.phase == CountdownWirePhase::BetweenRounds || + state.phase == CountdownWirePhase::Setup) { + renderCountdownBetweenRounds(online, state); + return; + } + + // Intro sub-phase \u2014 common to all round types + if (subPhase == SubPhase::Intro) { + renderCountdownIntro(state, elapsed, + lastAnimFrameMs < roundPhaseStartMs); + return; + } + + // Numbers sub-phases + if (subPhase == SubPhase::NumPicking) { + renderCountdownNumPicking(state, numUi.largeCount); + return; + } + if (subPhase == SubPhase::NumThinking) { + flip7::countdown::NumbersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.numbersProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownNumThinking(state, proj, elapsed, + lastAnimFrameMs < roundPhaseStartMs); + return; + } + if (subPhase == SubPhase::NumClaimEntry) { + flip7::countdown::NumbersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.numbersProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownNumClaimEntry(state, proj, numUi.draftValue, + numUi.claimSent); + return; + } + if (subPhase == SubPhase::NumClaimReveal) { + flip7::countdown::NumbersRoundProjection proj{}; + int32_t hClaim = 0, gClaim = 0; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.numbersProjection(); + auto* nr = countdownEngine.numbersRound(); + if (nr) { + hClaim = nr->claimFor(state.hostBoardId); + gClaim = nr->claimFor(state.guestBoardId); + } + portEXIT_CRITICAL(&gameMux); + renderCountdownNumClaimReveal(state, proj, hClaim, gClaim); + return; + } + if (subPhase == SubPhase::NumPresentPlayerA || + subPhase == SubPhase::NumPresentPlayerB) { + flip7::countdown::NumbersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.numbersProjection(); + portEXIT_CRITICAL(&gameMux); + // Determine if this board is the active presenter + // (simple heuristic: PlayerA = host, PlayerB = guest for now) + bool isPresenterA = (boardId == state.hostBoardId); + bool isActive = (subPhase == SubPhase::NumPresentPlayerA && isPresenterA) || + (subPhase == SubPhase::NumPresentPlayerB && !isPresenterA); + renderCountdownNumPresenting(state, proj, isActive); + return; + } + if (subPhase == SubPhase::NumResult) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, online); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_GREEN, TFT_BLACK); + display.drawString("ROUND COMPLETE", 160, 120, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Next round starting...", 160, 175, 2); + return; + } + + // Letters and Conundrum phases — defined after helpers below. + renderCountdownLetConPhases(online, state, subPhase, elapsed); +} + +// --------------------------------------------------------------------------- +// Letters render helpers +// --------------------------------------------------------------------------- + +// 9-letter strip reused across LetThinking, LetPresenting, LetResult. +// tappable=true draws each tile as a pressable button. +// usedMask: bit N set = tile N dimmed (already placed in word). +static void renderLetterStrip(const char* letters, uint8_t count, + bool tappable = false, + uint16_t usedMask = 0) { + constexpr int16_t W = 30, H = 34, GAP = 4; + int16_t startX = static_cast((320 - count * (W + GAP) + GAP) / 2); + for (uint8_t i = 0; i < count; ++i) { + int16_t tx = static_cast(startX + i * (W + GAP)); + bool used = tappable && ((usedMask >> i) & 1u); + uint16_t bg = used ? TFT_DARKGREY + : (tappable ? TFT_DARKCYAN : 0x1082 /*dark navy*/); + display.fillRoundRect(tx, kCdTileY, W, H, 5, bg); + display.drawRoundRect(tx, kCdTileY, W, H, 5, TFT_WHITE); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, bg); + char s[2] = {letters[i], '\0'}; + display.drawString(s, tx + W / 2, kCdTileY + H / 2, 2); + } +} + +// Vowel button row (A E I O U) for LetPicking. +static void renderVowelButtons(uint8_t vowelCount) { + constexpr int16_t W = 50, H = 44, Y = 95; + const char vowels[5] = {'A', 'E', 'I', 'O', 'U'}; + for (int i = 0; i < 5; ++i) { + int16_t bx = static_cast(10 + i * 60); + uint16_t bg = (vowelCount >= 5) ? TFT_DARKGREY : TFT_DARKCYAN; + display.fillRoundRect(bx, Y, W, H, 8, bg); + display.drawRoundRect(bx, Y, W, H, 8, TFT_WHITE); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, bg); + char s[2] = {vowels[i], '\0'}; + display.drawString(s, bx + W / 2, Y + H / 2, 4); + } +} + +// Consonant keyboard (4 rows) for LetPicking. +static void renderConsonantKeyboard(uint8_t consonantCount) { + // BCDFGH / JKLMNP / QRSTVW / XYZ + const char rows[4][7] = { + {'B','C','D','F','G','H', 0}, + {'J','K','L','M','N','P', 0}, + {'Q','R','S','T','V','W', 0}, + {'X','Y','Z', 0, 0, 0, 0}, + }; + const int rowLen[4] = {6, 6, 6, 3}; + constexpr int16_t W = 42, H = 32, GAP = 4; + bool maxed = (consonantCount >= 6); + for (int row = 0; row < 4; ++row) { + int16_t startX = static_cast( + (320 - rowLen[row] * (W + GAP) + GAP) / 2); + int16_t by = static_cast(88 + row * (H + GAP)); + for (int col = 0; col < rowLen[row]; ++col) { + int16_t bx = static_cast(startX + col * (W + GAP)); + uint16_t bg = maxed ? TFT_DARKGREY : TFT_DARKGREY; + // Consonants always dark-grey unless enabled — keep simple + bg = maxed ? 0x2104 /*very dark*/ : TFT_DARKGREY; + display.fillRoundRect(bx, by, W, H, 5, bg); + display.drawRoundRect(bx, by, W, H, 5, TFT_WHITE); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, bg); + char s[2] = {rows[row][col], '\0'}; + display.drawString(s, bx + W / 2, by + H / 2, 2); + } + } +} + +static void renderCountdownLetPicking(const CountdownWireState& state, + const flip7::countdown::LettersRoundProjection& proj, + bool showConsonants) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + + bool isChooser = (boardId == state.chooserBoardId); + + // Drawn-letters progress strip + if (proj.letterCount > 0) + renderLetterStrip(proj.letters, proj.letterCount); + + display.setTextDatum(MC_DATUM); + if (!isChooser) { + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Waiting for chooser...", 160, 130, 2); + return; + } + + // Toggle tabs: [VOWELS] [CONSONANTS] + uint16_t vbg = showConsonants ? TFT_DARKGREY : TFT_DARKCYAN; + uint16_t cbg = showConsonants ? TFT_DARKGREY : TFT_DARKGREY; + // Actually light up the active tab + vbg = !showConsonants ? TFT_DARKCYAN : TFT_DARKGREY; + cbg = showConsonants ? TFT_DARKGREY : TFT_DARKGREY; + display.fillRoundRect(5, 50, 100, 28, 6, vbg); + display.setTextColor(TFT_WHITE, vbg); + display.drawString("VOWELS", 55, 64, 2); + display.fillRoundRect(215, 50, 100, 28, 6, cbg); + display.setTextColor(TFT_WHITE, cbg); + display.drawString("CONSONANTS", 265, 64, 2); + + // Progress label + char prog[36]; + snprintf(prog, sizeof(prog), "V:%u/4 C:%u/5 Total:%u/9", + static_cast(proj.letterCount), // rough + 0u, static_cast(proj.letterCount)); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString(prog, 160, 83, 2); + + if (!showConsonants) { + // Count vowels in drawn set + uint8_t vc = 0; + for (uint8_t i = 0; i < proj.letterCount; ++i) { + char c = proj.letters[i]; + if (c=='A'||c=='E'||c=='I'||c=='O'||c=='U') ++vc; + } + renderVowelButtons(vc); + } else { + uint8_t cc = 0; + for (uint8_t i = 0; i < proj.letterCount; ++i) { + char c = proj.letters[i]; + if (c!='A'&&c!='E'&&c!='I'&&c!='O'&&c!='U') ++cc; + } + renderConsonantKeyboard(cc); + } + + if (proj.letterCount >= 9) { + display.fillRoundRect(95, 200, 130, 34, 8, TFT_DARKGREEN); + display.setTextColor(TFT_WHITE, TFT_DARKGREEN); + display.drawString("LOCK IN", 160, 217, 4); + } +} + +static void renderCountdownLetThinking(const CountdownWireState& state, + const flip7::countdown::LettersRoundProjection& proj, + uint32_t elapsedMs, bool firstFrame) { + if (firstFrame) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderLetterStrip(proj.letters, proj.letterCount); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Make the longest word you can", 160, 100, 2); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Your time starts now", 160, 120, 2); + } + display.fillCircle(kCdTimerCX, kCdTimerCY, kCdTimerR + 3, TFT_BLACK); + renderCountdownAnimation(kCdTimerCX, kCdTimerCY, kCdTimerR, + elapsedMs, 30000); +} + +static void renderCountdownLetClaimEntry(const CountdownWireState& state, + const flip7::countdown::LettersRoundProjection& proj, + uint8_t selectedLen, bool sent) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderLetterStrip(proj.letters, proj.letterCount); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Longest word you found:", 160, 95, 2); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Keep your claim private", 160, 113, 2); + + if (sent) { + display.setTextColor(TFT_GREEN, TFT_BLACK); + display.drawString("Claim sent!", 160, 155, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Waiting for opponent...", 160, 190, 2); + return; + } + + // 9 digit buttons (1-9) in a row + constexpr int16_t DW = 28, DH = 34, DY = 130, DGAP = 3; + int16_t startX = static_cast((320 - 9*(DW+DGAP)+DGAP) / 2); + for (int d = 1; d <= 9; ++d) { + int16_t bx = static_cast(startX + (d-1)*(DW+DGAP)); + uint16_t bg = (selectedLen == d) ? TFT_CYAN : TFT_DARKGREY; + uint16_t fg = (selectedLen == d) ? TFT_BLACK : TFT_WHITE; + display.fillRoundRect(bx, DY, DW, DH, 5, bg); + display.drawRoundRect(bx, DY, DW, DH, 5, TFT_WHITE); + display.setTextDatum(MC_DATUM); + display.setTextColor(fg, bg); + display.drawNumber(d, bx + DW/2, DY + DH/2, 2); + } + + uint16_t sbg = (selectedLen > 0) ? TFT_DARKGREEN : TFT_DARKGREY; + display.fillRoundRect(95, 180, 130, 34, 8, sbg); + display.drawRoundRect(95, 180, 130, 34, 8, TFT_WHITE); + display.setTextColor(TFT_WHITE, sbg); + display.drawString("SUBMIT CLAIM", 160, 197, 2); +} + +static void renderCountdownLetClaimReveal(const CountdownWireState& state, + const flip7::countdown::LettersRoundProjection& proj, + uint8_t hostClaim, uint8_t guestClaim) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderLetterStrip(proj.letters, proj.letterCount); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("CLAIMS REVEALED", 160, 58, 4); + display.setTextColor(TFT_WHITE, TFT_BLACK); + char hbuf[24], gbuf[24]; + snprintf(hbuf, sizeof(hbuf), "HOST: %u letters", hostClaim); + snprintf(gbuf, sizeof(gbuf), "GUEST: %u letters", guestClaim); + display.drawString(hbuf, 160, 120, 4); + display.drawString(gbuf, 160, 158, 4); + + uint8_t bigger = (hostClaim >= guestClaim) ? hostClaim : guestClaim; + bool hostFirst = (hostClaim >= guestClaim); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + char who[24]; + snprintf(who, sizeof(who), "%s presents first", + hostFirst ? "HOST" : "GUEST"); + display.drawString(who, 160, 195, 2); + (void)bigger; +} + +static void renderCountdownLetPresenting(const CountdownWireState& state, + const flip7::countdown::LettersRoundProjection& proj, + bool isPresenter, + const char* word, uint8_t wordLen, + bool sent) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + + // Build used-letter bitmask: count letters in word vs available + uint16_t usedMask = 0; + if (isPresenter && wordLen > 0) { + char pool[10]{}; + memcpy(pool, proj.letters, proj.letterCount); + for (uint8_t wi = 0; wi < wordLen; ++wi) { + for (uint8_t ti = 0; ti < proj.letterCount; ++ti) { + if (!(usedMask >> ti & 1u) && pool[ti] == word[wi]) { + usedMask |= (1u << ti); + break; + } + } + } + } + renderLetterStrip(proj.letters, proj.letterCount, isPresenter && !sent, + usedMask); + + display.setTextDatum(MC_DATUM); + if (!isPresenter) { + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("Opponent is presenting...", 160, 110, 2); + if (wordLen > 0) { + // Show word being built in real-time (updated via wire) + char wbuf[12]{}; + memcpy(wbuf, word, wordLen); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawString(wbuf, 160, 150, 4); + } + return; + } + + // Word strip (building) + char wbuf[12]{}; + memcpy(wbuf, word, wordLen); + display.fillRect(0, 95, 320, 40, TFT_BLACK); + display.drawRect(10, 98, 230, 34, TFT_DARKGREY); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.setTextDatum(ML_DATUM); + display.drawString(wordLen > 0 ? wbuf : "_", 18, 115, 4); + display.setTextDatum(MC_DATUM); + + if (!sent) { + // CLEAR and PRESENT buttons + display.fillRoundRect(250, 98, 64, 34, 6, TFT_MAROON); + display.setTextColor(TFT_WHITE, TFT_MAROON); + display.drawString("CLR", 282, 115, 2); + + uint16_t pbg = (wordLen > 0) ? TFT_DARKGREEN : TFT_DARKGREY; + display.fillRoundRect(95, 155, 130, 34, 8, pbg); + display.drawRoundRect(95, 155, 130, 34, 8, TFT_WHITE); + display.setTextColor(TFT_WHITE, pbg); + display.drawString("PRESENT WORD", 160, 172, 2); + } else { + display.setTextColor(TFT_GREEN, TFT_BLACK); + display.drawString("Waiting for verification...", 160, 175, 2); + } +} + +static void renderCountdownLetVerification(const CountdownWireState& state, + const char* word, uint8_t wordLen, + bool isVerifier, bool sent) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString(isVerifier ? "VERIFY WORD" : "Waiting for verification...", + 160, 55, 4); + + // Show the word letter by letter in boxes + if (wordLen > 0) { + constexpr int16_t W = 30, H = 38, GAP = 4; + int16_t startX = static_cast( + (320 - wordLen * (W + GAP) + GAP) / 2); + display.setTextColor(TFT_WHITE, 0x18C3 /*dark teal*/); + for (uint8_t i = 0; i < wordLen; ++i) { + int16_t bx = static_cast(startX + i * (W + GAP)); + display.fillRect(bx, 100, W, H, 0x18C3); + display.drawRect(bx, 100, W, H, TFT_WHITE); + char s[2] = {word[i], '\0'}; + display.drawString(s, bx + W/2, 100 + H/2, 2); + } + } + + if (isVerifier && !sent) { + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Is this in the dictionary?", 160, 158, 2); + + display.fillRoundRect(20, 178, 120, 44, 10, TFT_DARKGREEN); + display.drawRoundRect(20, 178, 120, 44, 10, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_DARKGREEN); + display.drawString("YES", 80, 200, 4); + + display.fillRoundRect(180, 178, 120, 44, 10, TFT_MAROON); + display.drawRoundRect(180, 178, 120, 44, 10, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_MAROON); + display.drawString("NO", 240, 200, 4); + } +} + +static void renderCountdownLetResult(const CountdownWireState& state, + const flip7::countdown::LettersRoundProjection& proj, + bool accepted, const char* word, + uint8_t wordLen, int32_t points, + bool morePresentersComing) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + renderLetterStrip(proj.letters, proj.letterCount); + + display.setTextDatum(MC_DATUM); + if (accepted) { + display.setTextColor(TFT_GREEN, TFT_BLACK); + display.drawString("ACCEPTED!", 160, 65, 4); + char wbuf[12]{}; + memcpy(wbuf, word, wordLen); + char detail[32]; + snprintf(detail, sizeof(detail), "%s \u2014 %u letters", wbuf, + static_cast(wordLen)); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawString(detail, 160, 115, 2); + char ptsbuf[16]; + snprintf(ptsbuf, sizeof(ptsbuf), "+%ld POINTS", + static_cast(points)); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString(ptsbuf, 160, 145, 4); + } else { + display.setTextColor(TFT_RED, TFT_BLACK); + display.drawString("REJECTED", 160, 90, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("0 POINTS", 160, 145, 4); + } + if (morePresentersComing) { + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("Next up: opponent", 160, 198, 2); + } + (void)proj; +} + +// --------------------------------------------------------------------------- +// Conundrum render helpers +// --------------------------------------------------------------------------- + +// Compute the pixel centre of the i-th letter in the circle. +// Uses integer maths; accurate enough for a 320x240 touch screen. +static void conCirclePos(uint8_t idx, uint8_t circleOrder[9], + int16_t cx, int16_t cy, int16_t r, + int16_t& outX, int16_t& outY) { + // Angles: 0° = top, clockwise. i * 40°. + int32_t deg = static_cast(circleOrder[idx]) * 40 - 90; + float rad = deg * 3.14159f / 180.0f; + outX = static_cast(cx + static_cast(r * cosf(rad))); + outY = static_cast(cy + static_cast(r * sinf(rad))); +} + +static void renderCountdownConActive(const CountdownWireState& state, + const flip7::countdown::ConundrumRoundProjection& proj, + const ConUiState& con, + uint32_t elapsedMs) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + + constexpr int16_t CX = 160, CY = 125, CR = 75; + constexpr int16_t TW = 26, TH = 26; + + // Hint + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + char hint[52]{}; + strncpy(hint, proj.hint, 50); + display.drawString(hint, CX, 44, 2); + + // "Good Try" flash + if (con.showGoodTry) { + display.setTextColor(TFT_ORANGE, TFT_BLACK); + display.drawString("Good try! Try again...", CX, 60, 2); + } + + // Circular letter tiles + for (uint8_t i = 0; i < 9; ++i) { + int16_t tx, ty; + conCirclePos(i, const_cast(con.circleOrder), CX, CY, CR, + tx, ty); + tx = static_cast(tx - TW / 2); + ty = static_cast(ty - TH / 2); + + // Check if this position has been selected + bool sel = false; + for (uint8_t s = 0; s < con.selectedCount; ++s) { + if (con.selected[s] == i) { sel = true; break; } + } + uint16_t bg = sel ? TFT_DARKGREY : TFT_MAGENTA; + display.fillRoundRect(tx, ty, TW, TH, 4, bg); + display.drawRoundRect(tx, ty, TW, TH, 4, TFT_WHITE); + display.setTextColor(TFT_WHITE, bg); + // Map circle position to scramble letter via circleOrder + char s[2] = {proj.scramble[con.circleOrder[i]], '\0'}; + display.drawString(s, tx + TW/2, ty + TH/2, 2); + } + + // Reshuffle icon (top-right, outside header) + display.fillRoundRect(284, 38, 30, 22, 4, TFT_DARKGREY); + display.drawRoundRect(284, 38, 30, 22, 4, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_DARKGREY); + display.drawString("~>", 299, 49, 2); + + // Answer strip + for (uint8_t i = 0; i < 9; ++i) { + int16_t bx = static_cast(6 + i * 34); + uint16_t bg = (i < con.selectedCount) ? TFT_DARKCYAN : TFT_DARKGREY; + display.fillRect(bx, 205, 30, 28, bg); + display.drawRect(bx, 205, 30, 28, TFT_WHITE); + if (i < con.selectedCount) { + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_WHITE, bg); + char ch[2] = {proj.scramble[con.circleOrder[con.selected[i]]], '\0'}; + display.drawString(ch, bx + 15, 219, 2); + } + } + + // Timer (top-right corner of circle area) + renderCountdownAnimation(kCdTimerCX, kCdTimerCY, kCdTimerR, + elapsedMs, 30000); +} + +static void renderCountdownConResult(bool won, const char* solution) { + display.fillScreen(TFT_BLACK); + display.setTextDatum(MC_DATUM); + if (won) { + display.setTextColor(TFT_GREEN, TFT_BLACK); + display.drawString("CONUNDRUM SOLVED!", 160, 60, 4); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawString(solution, 160, 120, 4); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("YOU WIN! +10 POINTS", 160, 175, 4); + } else { + display.setTextColor(TFT_CYAN, TFT_BLACK); + display.drawString("CONUNDRUM SOLVED", 160, 60, 4); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawString(solution, 160, 120, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("Good try!", 160, 175, 2); + } +} + +static void renderCountdownConNoWinner(const char* solution) { + display.fillScreen(TFT_BLACK); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_RED, TFT_BLACK); + display.drawString("TIME'S UP", 160, 60, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString("The answer was:", 160, 120, 2); + display.setTextColor(TFT_WHITE, TFT_BLACK); + display.drawString(solution, 160, 155, 4); + display.setTextColor(TFT_DARKGREY, TFT_BLACK); + display.drawString("No points awarded", 160, 205, 2); +} + +// --------------------------------------------------------------------------- +// Letters + Conundrum sub-phase dispatcher +// --------------------------------------------------------------------------- +static void renderCountdownLetConPhases( // NOLINT + bool online, const CountdownWireState& state, + CountdownRoundSubPhase subPhase, uint32_t elapsed) { + using SubPhase = CountdownRoundSubPhase; + + // Letters sub-phases + if (subPhase == SubPhase::LetPicking) { + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownLetPicking(state, proj, letUi.showConsonants); + return; + } + if (subPhase == SubPhase::LetThinking) { + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownLetThinking(state, proj, elapsed, + lastAnimFrameMs < roundPhaseStartMs); + return; + } + if (subPhase == SubPhase::LetClaimEntry) { + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownLetClaimEntry(state, proj, + letUi.claimLength, letUi.claimSent); + return; + } + if (subPhase == SubPhase::LetClaimReveal) { + flip7::countdown::LettersRoundProjection proj{}; + uint8_t hClaim = 0, gClaim = 0; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + auto* lr = countdownEngine.lettersRound(); + if (lr) { + hClaim = lr->claimFor(state.hostBoardId); + gClaim = lr->claimFor(state.guestBoardId); + } + portEXIT_CRITICAL(&gameMux); + renderCountdownLetClaimReveal(state, proj, hClaim, gClaim); + return; + } + if (subPhase == SubPhase::LetPresentPlayerA || + subPhase == SubPhase::LetPresentPlayerB) { + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + bool presenterIsHost = letUi.presenterIsHost; + bool aPhase = (subPhase == SubPhase::LetPresentPlayerA); + bool isPresenter = + (aPhase && ((presenterIsHost && boardId == state.hostBoardId) || + (!presenterIsHost && boardId == state.guestBoardId))) || + (!aPhase && ((presenterIsHost && boardId == state.guestBoardId) || + (!presenterIsHost && boardId == state.hostBoardId))); + bool inVerifyMode = letUi.presentationSent; + if (inVerifyMode) { + // Show verification UI to the other board; waiting for presenter + bool isVerifier = !isPresenter; + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + uint32_t presenterBoardId = + (aPhase && presenterIsHost) ? state.hostBoardId + : state.guestBoardId; + const std::string& pw = + lr ? lr->presentedWordFor(presenterBoardId) : std::string{}; + portEXIT_CRITICAL(&gameMux); + renderCountdownLetVerification(state, pw.c_str(), + static_cast(pw.size()), + isVerifier, letUi.verificationSent); + } else { + renderCountdownLetPresenting(state, proj, isPresenter, + letUi.word, letUi.wordLen, + letUi.presentationSent); + } + return; + } + if (subPhase == SubPhase::LetResult) { + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + // Show accepted/points from last verification result + bool accepted = letUi.verificationAnswer; + int32_t points = accepted + ? (letUi.claimLength == 9 ? 18 : letUi.claimLength) + : 0; + renderCountdownLetResult(state, proj, accepted, + letUi.word, letUi.wordLen, + points, false); + return; + } + + // Conundrum sub-phases + if (subPhase == SubPhase::ConReady) { + renderCountdownIntro(state, elapsed, + lastAnimFrameMs < roundPhaseStartMs); // reuse intro screen + return; + } + if (subPhase == SubPhase::ConActive) { + // "Good Try" auto-clear after 1.5 seconds + if (conUi.showGoodTry && millis() - conUi.goodTryMs > 1500) { + conUi.showGoodTry = false; + } + flip7::countdown::ConundrumRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.conundrumProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownConActive(state, proj, conUi, elapsed); + return; + } + if (subPhase == SubPhase::ConResult) { + flip7::countdown::ConundrumRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.conundrumProjection(); + portEXIT_CRITICAL(&gameMux); + bool won = (proj.winnerBoardId == boardId); + renderCountdownConResult(won, proj.scramble); // show solution + return; + } + if (subPhase == SubPhase::ConResultNoWinner) { + flip7::countdown::ConundrumRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.conundrumProjection(); + portEXIT_CRITICAL(&gameMux); + renderCountdownConNoWinner(proj.scramble); + return; + } + if (subPhase == SubPhase::HostTransfer || + subPhase == SubPhase::HostTransferAck) { + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, false); + display.setTextDatum(MC_DATUM); + bool isOldHost = (boardId != state.hostBoardId); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString(isOldHost ? "TRANSFERRING HOST..." + : "ACKNOWLEDGED", + 160, 90, 4); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString(isOldHost ? "Sending game state..." + : "State received. You are now the Host.", + 160, 150, 2); + if (subPhase == SubPhase::HostTransferAck) { + display.fillCircle(160, 195, 14, TFT_DARKGREEN); + display.drawCircle(160, 195, 14, TFT_WHITE); + display.setTextColor(TFT_WHITE, TFT_DARKGREEN); + display.drawString("OK", 160, 195, 2); + } + return; + } + + // Generic fallback for any remaining sub-phase + display.fillScreen(TFT_BLACK); + renderCountdownRoundHeader(state, online); + display.setTextDatum(MC_DATUM); + display.setTextColor(TFT_YELLOW, TFT_BLACK); + display.drawString("IN ROUND", 160, 100, 4); + char spLabel[32]; + snprintf(spLabel, sizeof(spLabel), "Sub-phase %u", state.roundSubPhase); + display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + display.drawString(spLabel, 160, 145, 2); +} + void renderScreen() { PuzzleState game{}; MastermindState mastermind{}; + CountdownWireState countdown{}; ScreenMode mode; bool ready; bool mastermindReady; + bool countdownReady; bool deliveryPending; bool mastermindDeliveryPending; portENTER_CRITICAL(&gameMux); game = puzzleState; mastermind = mastermindState; + countdown = countdownState; mode = screenMode; ready = stateReady; mastermindReady = mastermindStateReady; + countdownReady = countdownStateReady; deliveryPending = pendingDelivery.active; mastermindDeliveryPending = mastermindPendingDelivery.active; portEXIT_CRITICAL(&gameMux); @@ -747,6 +1992,8 @@ void renderScreen() { renderComplete(online, game); } else if (mode == ScreenMode::Mastermind && mastermindReady) { renderMastermind(online, mastermind, mastermindDeliveryPending); + } else if (mode == ScreenMode::Countdown && countdownReady) { + renderCountdown(online, countdown); } else if (mode == ScreenMode::Puzzle && ready) { renderPuzzle(online, game, deliveryPending); } else { @@ -777,6 +2024,24 @@ void prepareMastermindDraft(const MastermindState& state) { } else { draftCode = MastermindCode{{1, 1, 1, 1}}; } +} + +bool validCountdownIdentity(const CountdownWireState& state) { + const uint32_t host = std::min(boardId, gamePeerBoardId); + const uint32_t guest = std::max(boardId, gamePeerBoardId); + return state.hostBoardId == host && state.guestBoardId == guest; +} + +void queueCountdownAck(uint32_t targetBoardId, + const CountdownWireState& state, + MessageType acknowledgedType) { + countdownPendingAck = CountdownPendingAck{ + true, targetBoardId, state.gameId, state.revision, + countdownStateDigest(state), acknowledgedType}; +} + +void prepareMastermindDraft() { + draftCode = MastermindCode{{1, 1, 1, 1}}; selectedPeg = 0; } @@ -927,7 +2192,12 @@ void processStatePacket(const uint8_t* address, const uint8_t* data, } } if (accepted) { - activeGame = {puzzleState.gameId, ActiveGameKind::Puzzle}; + activeGame = { + puzzleState.gameId, + puzzleState.phase == PuzzlePhase::Exited + ? ActiveGameKind::Home + : ActiveGameKind::Puzzle, + }; mastermindPendingDelivery.active = false; mastermindReconciliationPending = false; mastermindRequestStateSoon = false; @@ -1073,15 +2343,181 @@ void processMastermindStatePacket(const uint8_t* address, mastermindStateReady = true; accepted = true; } else if (!duplicate) { - mastermindRequestStateSoon = true; - mastermindReconciliationPending = true; + mastermindRequestStateSoon = true; + mastermindReconciliationPending = true; + } + } + } + if ((accepted || duplicate) && mastermindPendingDelivery.active && + isMastermindDeliverySuperseded(mastermindPendingDelivery.state, + packet.state)) { + mastermindPendingDelivery.active = false; + } + if (accepted) { + pendingDelivery.active = false; + reconciliationPending = false; + requestStateSoon = false; + sendFullStateSoon = false; + sendMastermindFullStateSoon = false; + activeGame = { + mastermindState.gameId, + mastermindState.phase == MastermindPhase::Exited + ? ActiveGameKind::Home + : ActiveGameKind::Mastermind, + }; + screenMode = mastermindState.phase == MastermindPhase::Exited + ? ScreenMode::Home + : ScreenMode::Mastermind; + mastermindReconciliationPending = false; + noteMastermindPhase(mastermindState); + displayDirty = true; + } + if (accepted || duplicate) { + queueMastermindAck(packet.header.senderId, packet.state, type); + } + portEXIT_CRITICAL(&gameMux); +} + +void processMastermindAckPacket(const uint8_t* address, + const uint8_t* data, int length) { + if (length != sizeof(GameAckPacket)) { + return; + } + GameAckPacket packet{}; + memcpy(&packet, data, sizeof(packet)); + if (!validHeader(packet.header, MessageType::MastermindAck) || + packet.targetBoardId != boardId || + !matchesBoundPeer(address, packet.header.senderId) || + !acceptPeerSequence(MessageType::MastermindAck, + packet.header.sessionId, + packet.header.sequence)) { + return; + } + portENTER_CRITICAL(&gameMux); + if (mastermindPendingDelivery.active && + mastermindPendingDelivery.type == packet.acknowledgedType && + mastermindPendingDelivery.state.gameId == packet.gameId && + mastermindPendingDelivery.state.revision == packet.revision && + mastermindStateDigest(mastermindPendingDelivery.state) == + packet.stateDigest) { + mastermindPendingDelivery.active = false; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); +} + +void processMastermindStateRequest(const uint8_t* address, + const uint8_t* data, int length) { + if (length != sizeof(MastermindStateRequestPacket)) { + return; + } + MastermindStateRequestPacket packet{}; + memcpy(&packet, data, sizeof(packet)); + if (!validHeader(packet.header, MessageType::MastermindRequestState) || + !matchesBoundPeer(address, packet.header.senderId) || + !acceptPeerSequence(MessageType::MastermindRequestState, + packet.header.sessionId, + packet.header.sequence)) { + return; + } + portENTER_CRITICAL(&gameMux); + if (mastermindStateReady && screenMode == ScreenMode::Mastermind) { + const bool remoteOlder = packet.gameId < mastermindState.gameId || + (packet.gameId == mastermindState.gameId && + packet.revision < mastermindState.revision); + const bool remoteNewer = packet.gameId > mastermindState.gameId || + (packet.gameId == mastermindState.gameId && + packet.revision > mastermindState.revision); + const bool divergent = packet.gameId == mastermindState.gameId && + packet.revision == mastermindState.revision && + packet.stateDigest != mastermindStateDigest(mastermindState); + if (remoteOlder || (divergent && localIsHost())) { + sendMastermindFullStateSoon = true; + } else if (remoteNewer || (divergent && !localIsHost())) { + mastermindRequestStateSoon = true; + mastermindReconciliationPending = true; + } else { + mastermindReconciliationPending = false; + } + } + portEXIT_CRITICAL(&gameMux); +} + +void processCountdownStatePacket(const uint8_t* address, + const uint8_t* data, int length, + MessageType type) { + if (length != sizeof(CountdownStatePacket)) { + return; + } + CountdownStatePacket packet{}; + memcpy(&packet, data, sizeof(packet)); + if (!validHeader(packet.header, type) || + !matchesBoundPeer(address, packet.header.senderId) || + !isValidCountdownWireState(packet.state) || + !validCountdownIdentity(packet.state) || + !acceptPeerSequence(type, packet.header.sessionId, + packet.header.sequence)) { + return; + } + + bool accepted = false; + bool duplicate = false; + portENTER_CRITICAL(&gameMux); + const bool terminal = packet.state.phase == CountdownWirePhase::Exited; + if (!shouldAcceptGameState(activeGame, ActiveGameKind::Countdown, + packet.state.gameId, terminal)) { + portEXIT_CRITICAL(&gameMux); + return; + } + duplicate = countdownStateReady && + sameCountdownWireState(countdownState, packet.state); + const bool exitSignalApplied = + type == MessageType::CountdownState && terminal && + countdownStateReady && + applyCountdownExitSignal(countdownState, packet.state, + packet.header.senderId); + if (exitSignalApplied) { + countdownPendingDelivery.active = false; + countdownReconciliationPending = false; + accepted = true; + } else { + const CountdownVersionOrder order = + countdownStateReady + ? compareCountdownVersion(countdownState, packet.state) + : CountdownVersionOrder::Newer; + if (type == MessageType::CountdownFullState) { + const bool equalConflict = countdownStateReady && + order == CountdownVersionOrder::Same && !duplicate; + const bool peerIsAuthority = packet.header.senderId < boardId; + if (!countdownStateReady || + order == CountdownVersionOrder::Newer || + (equalConflict && peerIsAuthority)) { + countdownState = packet.state; + countdownStateReady = true; + countdownPendingDelivery.active = false; + countdownReconciliationPending = false; + accepted = true; + } else if (order == CountdownVersionOrder::Older || + (equalConflict && !peerIsAuthority)) { + sendCountdownFullStateSoon = true; + } + } else if (type == MessageType::CountdownState) { + if (!countdownStateReady || + (packet.state.gameId == countdownState.gameId && + packet.state.revision == countdownState.revision + 1)) { + countdownState = packet.state; + countdownStateReady = true; + accepted = true; + } else if (!duplicate) { + countdownRequestStateSoon = true; + countdownReconciliationPending = true; } } } - if ((accepted || duplicate) && mastermindPendingDelivery.active && - isMastermindDeliverySuperseded(mastermindPendingDelivery.state, + if ((accepted || duplicate) && countdownPendingDelivery.active && + isCountdownDeliverySuperseded(countdownPendingDelivery.state, packet.state)) { - mastermindPendingDelivery.active = false; + countdownPendingDelivery.active = false; } if (accepted) { pendingDelivery.active = false; @@ -1089,85 +2525,105 @@ void processMastermindStatePacket(const uint8_t* address, requestStateSoon = false; sendFullStateSoon = false; sendMastermindFullStateSoon = false; + sendCountdownFullStateSoon = false; activeGame = { - mastermindState.gameId, - mastermindState.phase == MastermindPhase::Exited + countdownState.gameId, + countdownState.phase == CountdownWirePhase::Exited ? ActiveGameKind::Home - : ActiveGameKind::Mastermind, + : ActiveGameKind::Countdown, }; - screenMode = mastermindState.phase == MastermindPhase::Exited + screenMode = countdownState.phase == CountdownWirePhase::Exited ? ScreenMode::Home - : ScreenMode::Mastermind; - mastermindReconciliationPending = false; - noteMastermindPhase(mastermindState); + : ScreenMode::Countdown; + countdownReconciliationPending = false; + + // Sync the local engine round so projections (target, tiles, letters, + // conundrum) are available for rendering — even on the guest board. + // The host already called startNextRound directly; skip if already done. + if (countdownState.phase == CountdownWirePhase::InRound && + boardId != countdownState.hostBoardId) { + bool needsRoundStart = + countdownEngine.activeRound() == nullptr || + countdownEngine.roundNumber() != countdownState.roundNumber; + if (needsRoundStart) { + auto rt = static_cast( + countdownState.roundType); + auto rng = flip7::countdown::makeRoundRandom( + countdownState.gameId, countdownState.roundNumber); + uint8_t largeCnt = countdownState.roundConfig; + countdownEngine.startNextRound(rt, rng, largeCnt); + } + } + displayDirty = true; + lastAnimFrameMs = 0; // force first-frame full redraw on new state } if (accepted || duplicate) { - queueMastermindAck(packet.header.senderId, packet.state, type); + queueCountdownAck(packet.header.senderId, packet.state, type); } portEXIT_CRITICAL(&gameMux); } -void processMastermindAckPacket(const uint8_t* address, - const uint8_t* data, int length) { - if (length != sizeof(GameAckPacket)) { +void processCountdownAckPacket(const uint8_t* address, + const uint8_t* data, int length) { + if (length != sizeof(CountdownAckPacket)) { return; } - GameAckPacket packet{}; + CountdownAckPacket packet{}; memcpy(&packet, data, sizeof(packet)); - if (!validHeader(packet.header, MessageType::MastermindAck) || + if (!validHeader(packet.header, MessageType::CountdownAck) || packet.targetBoardId != boardId || !matchesBoundPeer(address, packet.header.senderId) || - !acceptPeerSequence(MessageType::MastermindAck, + !acceptPeerSequence(MessageType::CountdownAck, packet.header.sessionId, packet.header.sequence)) { return; } portENTER_CRITICAL(&gameMux); - if (mastermindPendingDelivery.active && - mastermindPendingDelivery.type == packet.acknowledgedType && - mastermindPendingDelivery.state.gameId == packet.gameId && - mastermindPendingDelivery.state.revision == packet.revision && - mastermindStateDigest(mastermindPendingDelivery.state) == + if (countdownPendingDelivery.active && + countdownPendingDelivery.type == packet.acknowledgedType && + countdownPendingDelivery.state.gameId == packet.gameId && + countdownPendingDelivery.state.revision == packet.revision && + countdownStateDigest(countdownPendingDelivery.state) == packet.stateDigest) { - mastermindPendingDelivery.active = false; + countdownPendingDelivery.active = false; displayDirty = true; } portEXIT_CRITICAL(&gameMux); } -void processMastermindStateRequest(const uint8_t* address, - const uint8_t* data, int length) { - if (length != sizeof(MastermindStateRequestPacket)) { +void processCountdownStateRequest(const uint8_t* address, + const uint8_t* data, int length) { + if (length != sizeof(CountdownStateRequestPacket)) { return; } - MastermindStateRequestPacket packet{}; + CountdownStateRequestPacket packet{}; memcpy(&packet, data, sizeof(packet)); - if (!validHeader(packet.header, MessageType::MastermindRequestState) || + if (!validHeader(packet.header, MessageType::CountdownRequestState) || !matchesBoundPeer(address, packet.header.senderId) || - !acceptPeerSequence(MessageType::MastermindRequestState, + !acceptPeerSequence(MessageType::CountdownRequestState, packet.header.sessionId, packet.header.sequence)) { return; } portENTER_CRITICAL(&gameMux); - if (mastermindStateReady && screenMode == ScreenMode::Mastermind) { - const bool remoteOlder = packet.gameId < mastermindState.gameId || - (packet.gameId == mastermindState.gameId && - packet.revision < mastermindState.revision); - const bool remoteNewer = packet.gameId > mastermindState.gameId || - (packet.gameId == mastermindState.gameId && - packet.revision > mastermindState.revision); - const bool divergent = packet.gameId == mastermindState.gameId && - packet.revision == mastermindState.revision && - packet.stateDigest != mastermindStateDigest(mastermindState); + if (countdownStateReady && screenMode == ScreenMode::Countdown) { + const bool remoteOlder = packet.gameId < countdownState.gameId || + (packet.gameId == countdownState.gameId && + packet.revision < countdownState.revision); + const bool remoteNewer = packet.gameId > countdownState.gameId || + (packet.gameId == countdownState.gameId && + packet.revision > countdownState.revision); + const bool divergent = packet.gameId == countdownState.gameId && + packet.revision == countdownState.revision && + packet.stateDigest != countdownStateDigest(countdownState); if (remoteOlder || (divergent && localIsHost())) { - sendMastermindFullStateSoon = true; + sendCountdownFullStateSoon = true; } else if (remoteNewer || (divergent && !localIsHost())) { - mastermindRequestStateSoon = true; - mastermindReconciliationPending = true; + countdownRequestStateSoon = true; + countdownReconciliationPending = true; } else { - mastermindReconciliationPending = false; + countdownReconciliationPending = false; } } portEXIT_CRITICAL(&gameMux); @@ -1208,6 +2664,13 @@ void onPacketReceived(const uint8_t* address, const uint8_t* data, int length) { processMastermindAckPacket(address, data, length); } else if (header.type == MessageType::MastermindRequestState) { processMastermindStateRequest(address, data, length); + } else if (header.type == MessageType::CountdownState || + header.type == MessageType::CountdownFullState) { + processCountdownStatePacket(address, data, length, header.type); + } else if (header.type == MessageType::CountdownAck) { + processCountdownAckPacket(address, data, length); + } else if (header.type == MessageType::CountdownRequestState) { + processCountdownStateRequest(address, data, length); } xSemaphoreGive(protocolMutex); } @@ -1360,6 +2823,61 @@ void sendMastermindStateRequest() { xSemaphoreGive(protocolMutex); } +void sendCountdownStateIfCurrent(MessageType type, + const CountdownWireState& state) { + CountdownStatePacket packet{makeHeader(type), state}; + if (protocolMutex == nullptr || + xSemaphoreTake(protocolMutex, portMAX_DELAY) != pdTRUE) { + return; + } + portENTER_CRITICAL(&gameMux); + const bool snapshotMatches = + countdownStateReady && sameCountdownWireState(countdownState, state); + const bool terminalDelivery = + type == MessageType::CountdownState && + state.phase == CountdownWirePhase::Exited; + const bool shouldSend = + shouldSendActiveGameState(activeGame, ActiveGameKind::Countdown, + state.gameId, snapshotMatches, + terminalDelivery); + portEXIT_CRITICAL(&gameMux); + if (shouldSend) { + esp_now_send(expectedPeerAddress, reinterpret_cast(&packet), + sizeof(packet)); + } + xSemaphoreGive(protocolMutex); +} + +void sendCountdownAck(const CountdownPendingAck& ack) { + CountdownAckPacket packet{makeHeader(MessageType::CountdownAck), + ack.targetBoardId, + ack.gameId, + ack.revision, + ack.stateDigest, + ack.acknowledgedType, + {0, 0, 0}}; + esp_now_send(expectedPeerAddress, reinterpret_cast(&packet), + sizeof(packet)); +} + +void sendCountdownStateRequest() { + uint32_t gameId = 0; + uint32_t revision = 0; + uint32_t digest = 0; + portENTER_CRITICAL(&gameMux); + if (countdownStateReady) { + gameId = countdownState.gameId; + revision = countdownState.revision; + digest = countdownStateDigest(countdownState); + } + portEXIT_CRITICAL(&gameMux); + CountdownStateRequestPacket packet{ + makeHeader(MessageType::CountdownRequestState), gameId, revision, + digest}; + esp_now_send(expectedPeerAddress, reinterpret_cast(&packet), + sizeof(packet)); +} + void refreshPeerIdentity() { uint32_t peerId = 0; portENTER_CRITICAL(&linkMux); @@ -1418,7 +2936,7 @@ int8_t puzzlePositionAt(const PuzzleState& game, int16_t x, int16_t y) { void startPuzzle(const PuzzleSpec& spec) { PuzzleState started{}; portENTER_CRITICAL(&gameMux); - if (activeGame.kind != ActiveGameKind::Home) { + if (screenMode != ScreenMode::Home && activeGame.kind != ActiveGameKind::Home) { portEXIT_CRITICAL(&gameMux); return; } @@ -1455,7 +2973,7 @@ bool commitMastermindState(const MastermindState& next, MessageType type, !mastermindStateReady || !sameMastermindState(mastermindState, *expected))) || (expected == nullptr && - (activeGame.kind != ActiveGameKind::Home || + ((screenMode != ScreenMode::Home && activeGame.kind != ActiveGameKind::Home) || next.gameId <= activeGame.epoch))) { portEXIT_CRITICAL(&gameMux); return false; @@ -1504,6 +3022,38 @@ void startMastermind() { static_cast(started.gameId)); } +void startCountdown() { + uint32_t nextGameId = kNoActiveGameEpoch; + portENTER_CRITICAL(&gameMux); + if (screenMode != ScreenMode::Home && activeGame.kind != ActiveGameKind::Home) { + portEXIT_CRITICAL(&gameMux); + return; + } + nextGameId = nextActiveGameEpoch( + activeGame.epoch, stateReady ? puzzleState.gameId : 0, + mastermindStateReady ? mastermindState.gameId : 0); + if (nextGameId == kNoActiveGameEpoch) { + portEXIT_CRITICAL(&gameMux); + return; + } + countdownEngine.resetMatch(std::min(boardId, gamePeerBoardId), + std::max(boardId, gamePeerBoardId)); + const CountdownWireState started = makeCountdownMatch( + std::min(boardId, gamePeerBoardId), + std::max(boardId, gamePeerBoardId), nextGameId); + countdownState = started; + countdownStateReady = true; + activeGame = {nextGameId, ActiveGameKind::Countdown}; + screenMode = ScreenMode::Countdown; + countdownPendingDelivery = + CountdownPendingDelivery{true, MessageType::CountdownFullState, + started, 0}; + displayDirty = true; + portEXIT_CRITICAL(&gameMux); + Serial.printf("COUNTDOWN started id=%lu\n", + static_cast(nextGameId)); +} + void handleMastermindEditorTouch(int16_t x, int16_t y, bool secretEntry) { if (secretEntry && y >= 65 && y <= 112) { @@ -1611,6 +3161,596 @@ void handleTouch() { const bool exitPressed = x >= kExitX && x < kExitX + kExitWidth && y >= kExitY && y < kExitY + kExitHeight; + if (mode == ScreenMode::Mastermind && exitPressed) { + MastermindState mastermindSnapshot{}; + portENTER_CRITICAL(&gameMux); + mastermindSnapshot = mastermindState; + portEXIT_CRITICAL(&gameMux); + if (mastermindStateReady) { + MastermindState exited = mastermindSnapshot; + if (exitMastermindMatch(exited, boardId)) { + commitMastermindState(exited, MessageType::MastermindState, + &mastermindSnapshot); + } + } + return; + } + if (mode == ScreenMode::Countdown && exitPressed) { + CountdownWireState countdownSnapshot{}; + portENTER_CRITICAL(&gameMux); + countdownSnapshot = countdownState; + portEXIT_CRITICAL(&gameMux); + if (countdownStateReady) { + CountdownWireState exited = countdownSnapshot; + if (exitCountdownMatch(exited, boardId)) { + portENTER_CRITICAL(&gameMux); + countdownState = exited; + countdownStateReady = true; + activeGame = {exited.gameId, ActiveGameKind::Home}; + screenMode = ScreenMode::Home; + countdownPendingDelivery = + CountdownPendingDelivery{true, MessageType::CountdownState, + exited, 0}; + displayDirty = true; + portEXIT_CRITICAL(&gameMux); + } + } + return; + } + // --- NumPicking: chooser taps a large-count button (0-4) then CONFIRM --- + if (mode == ScreenMode::Countdown && + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::NumPicking) { + // Large-count buttons: x = 16 + i*58, y = kCdLcY, w=kCdLcW, h=kCdLcH + if (y >= kCdLcY && y < kCdLcY + kCdLcH) { + for (uint8_t i = 0; i <= 4; ++i) { + int16_t bx = static_cast(16 + i * 58); + if (x >= bx && x < bx + kCdLcW && + boardId == countdownState.chooserBoardId) { + numUi.largeCount = i; + displayDirty = true; + break; + } + } + } + // CONFIRM button: x=95..225, y=185..221 + if (y >= 185 && y < 221 && x >= 95 && x < 225 && + boardId == countdownState.chooserBoardId) { + // Chooser confirmed large count: transition to NumThinking + // Host starts the round in the engine; both advance sub-phase + CountdownWireState snap{}; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + portEXIT_CRITICAL(&gameMux); + if (countdownStateReady && boardId == snap.hostBoardId) { + auto random = flip7::countdown::makeRoundRandom( + snap.gameId, snap.roundNumber); + portENTER_CRITICAL(&gameMux); + countdownEngine.startNextRound( + flip7::countdown::RoundType::Numbers, random, + numUi.largeCount); + portEXIT_CRITICAL(&gameMux); + + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase( + advanced, boardId, + CountdownRoundSubPhase::NumThinking)) { + // Store largeCount in roundConfig so the guest can + // reconstruct the same puzzle via makeRoundRandom. + advanced.roundConfig = numUi.largeCount; + roundPhaseStartMs = millis(); + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + } + return; + } + + // --- NumClaimEntry: numpad interaction --- + if (mode == ScreenMode::Countdown && + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::NumClaimEntry && + !numUi.claimSent) { + // Numpad rows: y = kCdPadY + row*(kCdPadH+kCdPadGap) + // Cols: x = kCdPadX + col*(kCdPadW+kCdPadGap) + for (int row = 0; row < 4; ++row) { + int16_t by = static_cast(kCdPadY + row * (kCdPadH + kCdPadGap)); + if (y < by || y >= by + kCdPadH) continue; + for (int col = 0; col < 3; ++col) { + int16_t bx = static_cast(kCdPadX + col * (kCdPadW + kCdPadGap)); + if (x < bx || x >= bx + kCdPadW) continue; + const int digits[4][3] = {{7,8,9},{4,5,6},{1,2,3},{0,-1,-2}}; + int d = digits[row][col]; + if (d >= 0) { + int32_t next = numUi.draftValue * 10 + d; + if (next <= 999) numUi.draftValue = next; + } else if (d == -1) { // CLR + numUi.draftValue = 0; + } else { // SUBMIT (d==-2) + // Send claim to engine (host) — locally mark sent + portENTER_CRITICAL(&gameMux); + auto* nr = countdownEngine.numbersRound(); + if (nr) nr->submitClaim(boardId, numUi.draftValue); + portEXIT_CRITICAL(&gameMux); + numUi.claimSent = true; + // If host: also check if both claims are in and advance + // (guest claim will come via action packet — handled later) + } + displayDirty = true; + break; + } + break; + } + return; + } + + // ----------------------------------------------------------------------- + // Letters sub-phase touch handlers + // ----------------------------------------------------------------------- + + // LetPicking: chooser taps vowel/consonant letter buttons or LOCK IN + if (mode == ScreenMode::Countdown && + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::LetPicking && + boardId == countdownState.chooserBoardId) { + + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + + // Tab toggle: VOWELS (x=5..105, y=50..78) / CONSONANTS (x=215..315) + if (y >= 50 && y < 78) { + if (x >= 5 && x < 105) { letUi.showConsonants = false; displayDirty = true; return; } + if (x >= 215 && x < 315) { letUi.showConsonants = true; displayDirty = true; return; } + } + + char picked = 0; + bool isVowel = false; + + if (!letUi.showConsonants) { + // Vowel buttons: A E I O U at x=10+i*60, y=95..139, W=50, H=44 + if (y >= 95 && y < 139) { + const char vowels[5] = {'A','E','I','O','U'}; + for (int i = 0; i < 5; ++i) { + int16_t bx = static_cast(10 + i * 60); + if (x >= bx && x < bx + 50) { + picked = vowels[i]; + isVowel = true; + break; + } + } + } + } else { + // Consonant keyboard: BCDFGH / JKLMNP / QRSTVW / XYZ + const char rows[4][7] = { + {'B','C','D','F','G','H', 0}, + {'J','K','L','M','N','P', 0}, + {'Q','R','S','T','V','W', 0}, + {'X','Y','Z', 0, 0, 0, 0}, + }; + const int rowLen[4] = {6, 6, 6, 3}; + constexpr int16_t W = 42, H = 32, GAP = 4; + for (int row = 0; row < 4; ++row) { + int16_t startX = static_cast( + (320 - rowLen[row] * (W + GAP) + GAP) / 2); + int16_t by = static_cast(88 + row * (H + GAP)); + if (y < by || y >= by + H) continue; + for (int col = 0; col < rowLen[row]; ++col) { + int16_t bx = static_cast(startX + col * (W + GAP)); + if (x >= bx && x < bx + W && rows[row][col] != 0) { + picked = rows[row][col]; + isVowel = false; + break; + } + } + if (picked) break; + } + } + + if (picked && proj.letterCount < 9) { + // Apply to engine and sync via wire + flip7::countdown::CommandContext ctx{boardId, 0}; + std::vector payload = {static_cast(picked)}; + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + if (lr) { + lr->applyCommand(ctx, + isVowel ? flip7::countdown::CommandType::LetPickVowel + : flip7::countdown::CommandType::LetPickConsonant, + payload); + } + portEXIT_CRITICAL(&gameMux); + displayDirty = true; + + // Auto-advance to LetThinking when 9 letters are drawn (host only) + portENTER_CRITICAL(&gameMux); + auto proj2 = countdownEngine.lettersProjection(); + bool done = (proj2.letterCount >= 9); + portEXIT_CRITICAL(&gameMux); + if (done && boardId == countdownState.hostBoardId) { + CountdownWireState snap{}; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + portEXIT_CRITICAL(&gameMux); + CountdownWireState adv = snap; + if (advanceCountdownSubPhase(adv, boardId, + CountdownRoundSubPhase::LetThinking)) { + roundPhaseStartMs = millis(); + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = adv; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, adv, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + } + + // LOCK IN button: x=95..225, y=200..234 (when 9 letters) + if (proj.letterCount >= 9 && y >= 200 && y < 234 && + x >= 95 && x < 225 && boardId == countdownState.hostBoardId) { + CountdownWireState snap{}; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + portEXIT_CRITICAL(&gameMux); + CountdownWireState adv = snap; + if (advanceCountdownSubPhase(adv, boardId, + CountdownRoundSubPhase::LetThinking)) { + roundPhaseStartMs = millis(); + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = adv; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, adv, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + return; + } + + // LetClaimEntry: digit picker (1-9) + SUBMIT CLAIM + if (mode == ScreenMode::Countdown && + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::LetClaimEntry && + !letUi.claimSent) { + + // Digit row: y=130..164, x = (320 - 9*31+3)/2 + (d-1)*31 + constexpr int16_t DW = 28, DH = 34, DY = 130, DGAP = 3; + int16_t startX = static_cast((320 - 9*(DW+DGAP)+DGAP) / 2); + if (y >= DY && y < DY + DH) { + for (int d = 1; d <= 9; ++d) { + int16_t bx = static_cast(startX + (d-1)*(DW+DGAP)); + if (x >= bx && x < bx + DW) { + letUi.claimLength = static_cast(d); + displayDirty = true; + return; + } + } + } + // SUBMIT CLAIM button: x=95..225, y=180..214 + if (y >= 180 && y < 214 && x >= 95 && x < 225 && letUi.claimLength > 0) { + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + if (lr) lr->submitClaim(boardId, letUi.claimLength); + portEXIT_CRITICAL(&gameMux); + letUi.claimSent = true; + displayDirty = true; + } + return; + } + + // LetPresenting: tile taps, CLR, PRESENT WORD + if (mode == ScreenMode::Countdown && + (static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::LetPresentPlayerA || + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::LetPresentPlayerB) && + !letUi.presentationSent) { + + auto sp = static_cast( + countdownState.roundSubPhase); + bool aPhase = (sp == CountdownRoundSubPhase::LetPresentPlayerA); + bool isPresenter = + (aPhase && letUi.presenterIsHost && + boardId == countdownState.hostBoardId) || + (aPhase && !letUi.presenterIsHost && + boardId == countdownState.guestBoardId) || + (!aPhase && letUi.presenterIsHost && + boardId == countdownState.guestBoardId) || + (!aPhase && !letUi.presenterIsHost && + boardId == countdownState.hostBoardId); + if (!isPresenter) return; + + flip7::countdown::LettersRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.lettersProjection(); + portEXIT_CRITICAL(&gameMux); + + // Letter tile row: y=kCdTileY..kCdTileY+34 + if (y >= kCdTileY && y < kCdTileY + 34 && letUi.wordLen < 9) { + constexpr int16_t W = 30, GAP = 4; + int16_t startX = static_cast( + (320 - proj.letterCount * (W + GAP) + GAP) / 2); + for (uint8_t ti = 0; ti < proj.letterCount; ++ti) { + int16_t tx = static_cast(startX + ti * (W + GAP)); + if (x >= tx && x < tx + W) { + letUi.word[letUi.wordLen++] = proj.letters[ti]; + displayDirty = true; + return; + } + } + } + // CLR button: x=250..314, y=98..132 + if (y >= 98 && y < 132 && x >= 250 && x < 314 && letUi.wordLen > 0) { + letUi.wordLen--; + displayDirty = true; + return; + } + // PRESENT WORD button: x=95..225, y=155..189 + if (y >= 155 && y < 189 && x >= 95 && x < 225 && letUi.wordLen > 0) { + flip7::countdown::CommandContext ctx{boardId, 0}; + std::string wstr(letUi.word, letUi.wordLen); + std::vector payload(wstr.begin(), wstr.end()); + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + if (lr) lr->applyCommand(ctx, + flip7::countdown::CommandType::LetPresentComplete, payload); + portEXIT_CRITICAL(&gameMux); + letUi.presentationSent = true; + displayDirty = true; + } + return; + } + + // LetVerification: YES/NO buttons (verifier only) + if (mode == ScreenMode::Countdown && + (static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::LetPresentPlayerA || + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::LetPresentPlayerB) && + letUi.presentationSent && !letUi.verificationSent) { + + auto sp = static_cast( + countdownState.roundSubPhase); + bool aPhase = (sp == CountdownRoundSubPhase::LetPresentPlayerA); + bool presenterIsHost = letUi.presenterIsHost; + bool isVerifier = + !((aPhase && presenterIsHost && boardId == countdownState.hostBoardId) || + (aPhase && !presenterIsHost && boardId == countdownState.guestBoardId) || + (!aPhase && presenterIsHost && boardId == countdownState.guestBoardId) || + (!aPhase && !presenterIsHost && boardId == countdownState.hostBoardId)); + if (!isVerifier) return; + + // YES: x=20..140, y=178..222 + if (y >= 178 && y < 222 && x >= 20 && x < 140) { + letUi.verificationAnswer = true; + letUi.verificationSent = true; + flip7::countdown::CommandContext ctx{boardId, 0}; + std::vector payload = {1}; + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + if (lr) lr->applyCommand(ctx, + flip7::countdown::CommandType::LetVerifyWord, payload); + portEXIT_CRITICAL(&gameMux); + // Host advances to LetResult or PlayerB + if (boardId == countdownState.hostBoardId) { + CountdownWireState snap{}; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + portEXIT_CRITICAL(&gameMux); + CountdownWireState adv = snap; + auto nextSP = aPhase ? CountdownRoundSubPhase::LetPresentPlayerB + : CountdownRoundSubPhase::LetResult; + if (advanceCountdownSubPhase(adv, boardId, nextSP)) { + roundPhaseStartMs = millis(); + letUi = LetUiState{}; // reset for next presenter + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = adv; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, adv, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + displayDirty = true; + return; + } + // NO: x=180..300, y=178..222 + if (y >= 178 && y < 222 && x >= 180 && x < 300) { + letUi.verificationAnswer = false; + letUi.verificationSent = true; + flip7::countdown::CommandContext ctx{boardId, 0}; + std::vector payload = {0}; + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + if (lr) lr->applyCommand(ctx, + flip7::countdown::CommandType::LetVerifyWord, payload); + portEXIT_CRITICAL(&gameMux); + if (boardId == countdownState.hostBoardId) { + CountdownWireState snap{}; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + portEXIT_CRITICAL(&gameMux); + CountdownWireState adv = snap; + auto nextSP = aPhase ? CountdownRoundSubPhase::LetPresentPlayerB + : CountdownRoundSubPhase::LetResult; + if (advanceCountdownSubPhase(adv, boardId, nextSP)) { + roundPhaseStartMs = millis(); + letUi = LetUiState{}; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = adv; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, adv, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + displayDirty = true; + return; + } + return; + } + + // ----------------------------------------------------------------------- + // Conundrum touch handlers + // ----------------------------------------------------------------------- + + if (mode == ScreenMode::Countdown && + static_cast(countdownState.roundSubPhase) == + CountdownRoundSubPhase::ConActive) { + + constexpr int16_t CX = 160, CY = 125, CR = 75; + constexpr int16_t TW = 26, TH = 26; + + // Reshuffle button: x=284..314, y=38..60 + if (y >= 38 && y < 60 && x >= 284 && x < 314) { + // Fisher-Yates shuffle using millis() as entropy + uint32_t seed = millis(); + for (int i = 8; i > 0; --i) { + seed = seed * 1664525u + 1013904223u; + int j = seed % static_cast(i + 1); + uint8_t tmp = conUi.circleOrder[i]; + conUi.circleOrder[i] = conUi.circleOrder[j]; + conUi.circleOrder[j] = tmp; + } + displayDirty = true; + return; + } + + // Letter circles: check if tap is within TW/2 of any circle centre + flip7::countdown::ConundrumRoundProjection proj{}; + portENTER_CRITICAL(&gameMux); + proj = countdownEngine.conundrumProjection(); + portEXIT_CRITICAL(&gameMux); + + for (uint8_t i = 0; i < 9; ++i) { + // Skip already selected + bool alreadySel = false; + for (uint8_t s = 0; s < conUi.selectedCount; ++s) + if (conUi.selected[s] == i) { alreadySel = true; break; } + if (alreadySel) continue; + + int16_t tx, ty; + conCirclePos(i, conUi.circleOrder, CX, CY, CR, tx, ty); + if (abs(x - tx) <= TW/2 + 4 && abs(y - ty) <= TH/2 + 4) { + conUi.selected[conUi.selectedCount++] = i; + displayDirty = true; + + if (conUi.selectedCount == 9) { + // Build attempt string and submit + char attempt[10]{}; + for (uint8_t s = 0; s < 9; ++s) + attempt[s] = proj.scramble[conUi.circleOrder[conUi.selected[s]]]; + + flip7::countdown::CommandContext ctx{boardId, 0}; + std::vector payload(attempt, attempt + 9); + bool correct = false; + portENTER_CRITICAL(&gameMux); + auto* cr = countdownEngine.conundrumRound(); + if (cr) { + auto res = cr->applyCommand(ctx, + flip7::countdown::CommandType::ConSubmitAttempt, + payload); + correct = res.accepted; + } + portEXIT_CRITICAL(&gameMux); + + if (correct && boardId == countdownState.hostBoardId) { + // Advance to ConResult + CountdownWireState snap{}; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + portEXIT_CRITICAL(&gameMux); + CountdownWireState adv = snap; + if (advanceCountdownSubPhase(adv, boardId, + CountdownRoundSubPhase::ConResult)) { + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = adv; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, adv, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } else if (!correct) { + // Reset attempt — forced local reshuffle + conUi.selectedCount = 0; + conUi.showGoodTry = true; + conUi.goodTryMs = millis(); + // Shuffle circle order + uint32_t seed2 = millis(); + for (int si = 8; si > 0; --si) { + seed2 = seed2 * 1664525u + 1013904223u; + int j = seed2 % static_cast(si + 1); + uint8_t tmp = conUi.circleOrder[si]; + conUi.circleOrder[si] = conUi.circleOrder[j]; + conUi.circleOrder[j] = tmp; + } + } + } + return; + } + } + return; + } + if (mode == ScreenMode::Countdown && + y >= kCountdownRoundTypeY && + y < kCountdownRoundTypeY + kCountdownRoundTypeHeight) { + uint8_t roundType = 0; + if (x >= kCountdownNumbersX && + x < kCountdownNumbersX + kCountdownRoundTypeWidth) { + roundType = 1; // Numbers + } else if (x >= kCountdownLettersX && + x < kCountdownLettersX + kCountdownRoundTypeWidth) { + roundType = 2; // Letters + } else if (x >= kCountdownConundrumX && + x < kCountdownConundrumX + kCountdownRoundTypeWidth) { + roundType = 3; // Conundrum + } + if (roundType != 0) { + CountdownWireState countdownSnapshot{}; + portENTER_CRITICAL(&gameMux); + countdownSnapshot = countdownState; + portEXIT_CRITICAL(&gameMux); + // Chooser (not host) picks the round type. + if (countdownStateReady && + boardId == countdownSnapshot.chooserBoardId) { + CountdownWireState selected = countdownSnapshot; + if (selectCountdownRoundType(selected, boardId, roundType)) { + roundPhaseStartMs = millis(); + numUi = NumUiState{}; // reset per-round UI state + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, + countdownSnapshot)) { + countdownState = selected; + countdownStateReady = true; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, selected, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + } + return; + } if ((mode == ScreenMode::Puzzle || mode == ScreenMode::Complete) && exitPressed) { PuzzleState exited{}; @@ -1634,15 +3774,19 @@ void handleTouch() { } if (mode == ScreenMode::Home) { - if (localIsHost() && peerOnline() && !pending && y >= 82 && y < 171) { - if (x >= 10 && x < 155) { - startPuzzle({3, 3, PuzzleTheme::Planets}); - } else if (x >= 165 && x < 310) { - startPuzzle({4, 3, PuzzleTheme::Greek}); - } - } else if (localIsHost() && peerOnline() && x >= 45 && x < 275) { - if (y >= 185 && y < 240) { - startMastermind(); + if (localIsHost() && peerOnline() && !pending) { + if (y >= 75 && y < 145) { + if (x >= 10 && x < 155) { + startPuzzle({3, 3, PuzzleTheme::Planets}); + } else if (x >= 165 && x < 310) { + startPuzzle({4, 3, PuzzleTheme::Greek}); + } + } else if (y >= 150 && y < 220) { + if (x >= 10 && x < 155) { + startMastermind(); + } else if (x >= 165 && x < 310) { + startCountdown(); + } } } return; @@ -1710,6 +3854,9 @@ void serviceProtocol(uint32_t now) { MastermindPendingDelivery mastermindDelivery{}; MastermindPendingAck mastermindAck{}; MastermindState mastermindFullState{}; + CountdownPendingDelivery countdownDelivery{}; + CountdownPendingAck countdownAck{}; + CountdownWireState countdownFullState{}; bool sendDelivery = false; bool sendAckNow = false; bool sendFullNow = false; @@ -1718,10 +3865,16 @@ void serviceProtocol(uint32_t now) { bool sendMastermindAckNow = false; bool sendMastermindFullNow = false; bool requestMastermindNow = false; + bool sendCountdownDeliveryNow = false; + bool sendCountdownAckNow = false; + bool sendCountdownFullNow = false; + bool requestCountdownNow = false; portENTER_CRITICAL(&gameMux); const bool puzzleActive = activeGame.kind == ActiveGameKind::Puzzle; const bool mastermindActive = activeGame.kind == ActiveGameKind::Mastermind; + const bool countdownActive = + activeGame.kind == ActiveGameKind::Countdown; const bool discoveringGame = activeGame.kind == ActiveGameKind::Home; if (pendingDelivery.active && now - pendingDelivery.lastSentMs >= kDeliveryRetryMs) { @@ -1771,7 +3924,32 @@ void serviceProtocol(uint32_t now) { mastermindRequestStateSoon = false; requestMastermindNow = true; } - if (requestNow || requestMastermindNow) { + if (countdownPendingDelivery.active && + now - countdownPendingDelivery.lastSentMs >= kDeliveryRetryMs) { + countdownPendingDelivery.lastSentMs = now; + countdownDelivery = countdownPendingDelivery; + sendCountdownDeliveryNow = true; + } + if (countdownPendingAck.active) { + countdownAck = countdownPendingAck; + countdownPendingAck.active = false; + sendCountdownAckNow = true; + } + if (sendCountdownFullStateSoon && countdownStateReady && + countdownActive) { + sendCountdownFullStateSoon = false; + countdownFullState = countdownState; + sendCountdownFullNow = true; + } + if ((countdownActive || discoveringGame) && + (countdownRequestStateSoon || + (gamePeerBoardId != 0 && + (!countdownStateReady || countdownReconciliationPending) && + now - lastStateRequestMs >= kStateRequestIntervalMs))) { + countdownRequestStateSoon = false; + requestCountdownNow = true; + } + if (requestNow || requestMastermindNow || requestCountdownNow) { lastStateRequestMs = now; } portEXIT_CRITICAL(&gameMux); @@ -1802,6 +3980,20 @@ void serviceProtocol(uint32_t now) { if (requestMastermindNow) { sendMastermindStateRequest(); } + if (sendCountdownDeliveryNow) { + sendCountdownStateIfCurrent(countdownDelivery.type, + countdownDelivery.state); + } + if (sendCountdownAckNow) { + sendCountdownAck(countdownAck); + } + if (sendCountdownFullNow) { + sendCountdownStateIfCurrent(MessageType::CountdownFullState, + countdownFullState); + } + if (requestCountdownNow) { + sendCountdownStateRequest(); + } } void advanceMastermindRoundIfReady(uint32_t now) { @@ -1861,6 +4053,289 @@ void loop() { refreshPeerIdentity(); serviceProtocol(now); advanceMastermindRoundIfReady(now); + + // --- Countdown sub-phase auto-advance (host only) and animation ticks --- + { + CountdownWireState snap{}; + bool cdReady = false; + bool cdHost = false; + portENTER_CRITICAL(&gameMux); + snap = countdownState; + cdReady = countdownStateReady; + cdHost = (boardId == countdownState.hostBoardId); + portEXIT_CRITICAL(&gameMux); + + if (cdReady && snap.phase == CountdownWirePhase::InRound) { + using SP = CountdownRoundSubPhase; + auto sp = static_cast(snap.roundSubPhase); + + // Rate-limited re-render during timed animation sub-phases (~10 fps). + // Without the limit every 10ms loop tick causes a full fillScreen + // redraw which flickers visibly on the TFT. + // ConActive uses a slower rate (500ms) since tiles still need full redraws. + bool animating = (sp == SP::Intro || + sp == SP::NumThinking || + sp == SP::LetThinking); + bool conAnimating = (sp == SP::ConActive); + if ((animating && (now - lastAnimFrameMs >= kAnimFrameIntervalMs || + lastAnimFrameMs < roundPhaseStartMs)) || + (conAnimating && (now - lastAnimFrameMs >= 500 || + lastAnimFrameMs < roundPhaseStartMs))) { + lastAnimFrameMs = now; + portENTER_CRITICAL(&gameMux); + displayDirty = true; + portEXIT_CRITICAL(&gameMux); + } + + // Host-only: auto-advance when a timed phase expires. + if (cdHost) { + uint32_t elapsed = (now >= roundPhaseStartMs) + ? now - roundPhaseStartMs : 0; + CountdownRoundSubPhase nextSP = SP::None; + uint32_t timeout = 0; + + if (sp == SP::Intro) { + timeout = 5000; + // Advance to picking sub-phase for the selected round type. + auto rt = static_cast( + snap.roundType); + if (rt == flip7::countdown::RoundType::Numbers) + nextSP = SP::NumPicking; + else if (rt == flip7::countdown::RoundType::Letters) + nextSP = SP::LetPicking; + else + nextSP = SP::ConReady; + } else if (sp == SP::NumThinking) { + timeout = 30000; + nextSP = SP::NumClaimEntry; + } else if (sp == SP::LetThinking) { + timeout = 30000; + nextSP = SP::LetClaimEntry; + } else if (sp == SP::ConActive) { + timeout = 30000; + nextSP = SP::ConResultNoWinner; + } + + if (nextSP != SP::None && elapsed >= timeout) { + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase(advanced, boardId, nextSP)) { + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + + // Auto-advance result sub-phases → BetweenRounds (with host handoff) + bool isResultPhase = (sp == SP::NumResult || + sp == SP::LetResult || + sp == SP::ConResult || + sp == SP::ConResultNoWinner); + if (isResultPhase && elapsed >= 3000) { + // 1. Finalize the round in the engine and get the result. + std::optional result; + portENTER_CRITICAL(&gameMux); + if (countdownEngine.activeRound()) { + std::vector players = {snap.hostBoardId, + snap.guestBoardId}; + result = countdownEngine.activeRound()->tryFinalize(players); + if (result) { + countdownEngine.recordRoundResult(*result); + } + } + portEXIT_CRITICAL(&gameMux); + + // 2. Detect host change; build updated wire state. + uint32_t newHostId{}; + uint32_t newChooserId{}; + int32_t newHostScore{}; + int32_t newGuestScore{}; + portENTER_CRITICAL(&gameMux); + newHostId = countdownEngine.hostBoardId(); + newChooserId = countdownEngine.chooserBoardId(); + newHostScore = countdownEngine.hostPlayer().score; + newGuestScore = countdownEngine.guestPlayer().score; + portEXIT_CRITICAL(&gameMux); + + bool hostChanged = (newHostId != snap.hostBoardId); + + CountdownWireState updated = snap; + ++updated.revision; + ++updated.roundNumber; + updated.chooserBoardId = newChooserId; + updated.hostTerm += hostChanged ? 1 : 0; + + // Swap host/guest scores and IDs if leadership transferred. + if (hostChanged) { + updated.hostBoardId = newHostId; + updated.guestBoardId = snap.hostBoardId; + updated.hostScore = newGuestScore; + updated.guestScore = newHostScore; + updated.roundSubPhase = + static_cast(SP::HostTransfer); + } else { + updated.hostScore = newHostScore; + updated.guestScore = newGuestScore; + updated.roundSubPhase = 0; + updated.phase = CountdownWirePhase::BetweenRounds; + } + + roundPhaseStartMs = now; + // Reset per-round UI + numUi = NumUiState{}; + letUi = LetUiState{}; + conUi = ConUiState{}; + + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = updated; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, updated, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + + // HostTransfer: after 2s animation, commit and go to BetweenRounds + if (sp == SP::HostTransfer && elapsed >= 2000) { + CountdownWireState committed = snap; + committed.roundSubPhase = 0; + committed.phase = CountdownWirePhase::BetweenRounds; + ++committed.revision; + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = committed; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, committed, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + + // NumClaimEntry: if both claims are in (host side), auto-advance + if (sp == SP::NumClaimEntry && cdHost) { + portENTER_CRITICAL(&gameMux); + auto* nr = countdownEngine.numbersRound(); + bool both = nr && nr->bothClaimsSubmitted(); + portEXIT_CRITICAL(&gameMux); + if (both) { + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase(advanced, boardId, + SP::NumClaimReveal)) { + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + } + + // NumClaimReveal: after 2s, advance to presentation + if (sp == SP::NumClaimReveal && elapsed >= 2000) { + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase(advanced, boardId, + SP::NumPresentPlayerA)) { + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + + // LetClaimEntry: both claims in → reveal + if (sp == SP::LetClaimEntry && cdHost) { + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + bool both = lr && lr->bothClaimsSubmitted(); + portEXIT_CRITICAL(&gameMux); + if (both) { + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase(advanced, boardId, + SP::LetClaimReveal)) { + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + } + + // LetClaimReveal: after 2s → first presentation + if (sp == SP::LetClaimReveal && elapsed >= 2000) { + // Determine presenter order from claims + portENTER_CRITICAL(&gameMux); + auto* lr = countdownEngine.lettersRound(); + uint8_t hc = lr ? lr->claimFor(snap.hostBoardId) : 0; + uint8_t gc = lr ? lr->claimFor(snap.guestBoardId) : 0; + portEXIT_CRITICAL(&gameMux); + letUi.presenterIsHost = + (hc >= gc); // ties: host presents first + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase(advanced, boardId, + SP::LetPresentPlayerA)) { + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + + // ConReady: after 2s → ConActive + if (sp == SP::ConReady && elapsed >= 2000) { + // Start conundrum round in engine + portENTER_CRITICAL(&gameMux); + auto random = flip7::countdown::makeRoundRandom( + snap.gameId, snap.roundNumber); + countdownEngine.startNextRound( + flip7::countdown::RoundType::Conundrum, random); + // Reset conundrum circle order + for (uint8_t ci = 0; ci < 9; ++ci) conUi.circleOrder[ci] = ci; + portEXIT_CRITICAL(&gameMux); + + CountdownWireState advanced = snap; + if (advanceCountdownSubPhase(advanced, boardId, + SP::ConActive)) { + roundPhaseStartMs = now; + portENTER_CRITICAL(&gameMux); + if (sameCountdownWireState(countdownState, snap)) { + countdownState = advanced; + countdownPendingDelivery = CountdownPendingDelivery{ + true, MessageType::CountdownState, advanced, 0}; + displayDirty = true; + } + portEXIT_CRITICAL(&gameMux); + } + } + } + } + } + handleTouch(); const bool online = peerOnline(); diff --git a/test/test_debug_canvas/test_debug_canvas.cpp b/test/test_debug_canvas/test_debug_canvas.cpp new file mode 100644 index 0000000..aa8e6d2 --- /dev/null +++ b/test/test_debug_canvas/test_debug_canvas.cpp @@ -0,0 +1,20 @@ +#include + +#include "DebugCanvas.h" + +void test_debug_canvas_dimensions() +{ + TEST_ASSERT_EQUAL_INT16(240, DebugCanvas::WIDTH); + TEST_ASSERT_EQUAL_INT16(320, DebugCanvas::HEIGHT); +} + +void setup() +{ + UNITY_BEGIN(); + RUN_TEST(test_debug_canvas_dimensions); + UNITY_END(); +} + +void loop() +{ +}