jonnify·codemojex /codemojex/guesses-on-fair-lanes/the-guess-and-the-lock

Codemojex · C3.1 · a dive of Guesses on Fair Lanes

The guess and the lock.

A guess is six emoji codes. Before it can become work, Codemojex.Guesses.submit/3 runs a short admission gate and overlays the player’s locked positions — both server-side, against the system of record. The client sends six codes and a lock intent; the server decides what is real.

Grounding — echo/…/codemojex/game.ex (Codemojex.Guesses.submit/3, valid_guess?/2) · locks.ex (merge/3, the per-player lock hash). The play path from the scoring story feature’s submit setup.

The admission gate — the cond of guards, in order
r == nil → {:error, :no_game} Map.get(r, :status, :open) != :open → {:error, :closed} expired?(r, now) → {:error, :expired} not valid_guess?(r, emojis) → {:error, :bad_guess} true → merge · charge · enqueue
no game → Store.game(game) reads the game from the system of record. A missing game is {:error, :no_game} — the status is read from Postgres, not the cache, because it is mutable and must be authoritative.
§1

The admission gate

game.ex

submit/3’s head clause is when length(emojis) == 6, so a wrong-length input falls to the catch-all clause and is a plain :bad_guess — the arity of a guess is enforced by pattern match, not a runtime check. A live, unexpired game then validates the six codes against its own snapshotted keyboard: cell_codes, the randomized subset the game pinned at open (C2). A code from outside that subset is rejected before any money moves.

echo/…/codemojex/game.ex · Codemojex.Guesses.submit/3 source
def submit(game, player, emojis) when length(emojis) == 6 do
  r = Store.game(game)                    # the system of record, not the cache
  now = System.system_time(:millisecond)
  cond do
    r == nil                            -> {:error, :no_game}
    Map.get(r, :status, :open) != :open -> {:error, :closed}
    expired?(r, now)                    -> {:error, :expired}
    not valid_guess?(r, emojis)         -> {:error, :bad_guess}
    true ->
      guess = Locks.merge(game, player, emojis)   # locks overlaid → the scored guess
      # … charge the wallet, then enqueue (C3.2)
  end
end
def submit(_game, _player, _emojis), do: {:error, :bad_guess}

The guards run in order and short-circuit: the first that matches returns its error, and only true reaches the write. The surface turns each {:error, reason} into a rejection the player sees immediately, and turns the accepted branch into an on-its-way response — the score returns later, over the live channel.

§2

The lock — durable, server-side

locks.ex

A player sure of a position locks it. The lock is one field in a per-player Valkey hash for the round — key cm:{round}:lock:{player}, field the position 0..5, value the code — so it persists across guesses with no client state to lose. At submit time, Locks.merge/3 overlays the locked positions onto the six submitted codes; a locked position wins regardless of what the keyboard sent.

echo/…/codemojex/locks.ex · Codemojex.Locks.merge/3 source
# key: cm:{round}:lock:{player}   field: position 0..5   value: code
def merge(round, player, guess) do
  locks = locked(round, player)                 # %{pos => code} from the hash
  guess
  |> Enum.with_index()
  |> Enum.map(fn {code, i} -> Map.get(locks, i, code) end)  # lock wins at its position
end

So a confirmed cell is guaranteed on every subsequent guess: the player commits it once, and the server enforces it on the merged guess that becomes the job. The guess that gets scored is always the merged one, never the raw client input.

The overlay — submitted, locks, merged (positions 2 and 4 are locked)
012345 submitted (client) 07 31 18 22 44 09 locks (Valkey hash) — — 55 — 61 — merged → scored 07 31 55 22 61 09
submitted → the client sends six codes — 07 31 18 22 44 09 — each drawn from the game’s snapshotted keyboard. This is the raw input, before any lock is applied.
§3

References

sources · related

Sources

Related