// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {LobbyPhase} from './IPvpGameV2.sol'; // Optional liveness standard for IPvpGameV2 games. A game that implements it lets any host, keeper // or guest read what the lobby is waiting for and unblock it through the ordinary // `submitLobbyAction`, without knowing the game's own action encoding. // // A wait is one thing the lobby is blocked on: a player who owes a move or a reveal, a round window // that has to be closed, or a challenge window that has to expire. Turn-based games publish one // wait at a time; simultaneous games publish one per player who has not acted yet. // // Reserved `actionData` payloads are a 4-byte tag followed by a 32-byte subject: // `abi.encodePacked(tag, subject)`. 36 bytes is never a whole number of ABI words, so they cannot // collide with a game's own actions; games using packed encodings must avoid 36-byte payloads. // A zero subject means "whichever wait qualifies". // // SKIP — anyone, after a wait's `dueAt`: the game plays or passes the missing input and the // match continues. Subject = the wait's `subject`. // FORFEIT — anyone, after a wait's `forfeitAt`: that wait's `actor` loses. Subject = the actor, // left-padded to 32 bytes. // RESIGN — a player still in contention, whenever the lobby accepts actions: the caller loses. // ADVANCE — anyone, after an actor-less wait's `dueAt`: the game closes the round, settles the // challenge, or ends a stalled match on its own terms. bytes4 constant PVP_LIVENESS_SKIP = bytes4(keccak256('PvpLiveness.SKIP')); bytes4 constant PVP_LIVENESS_FORFEIT = bytes4(keccak256('PvpLiveness.FORFEIT')); bytes4 constant PVP_LIVENESS_RESIGN = bytes4(keccak256('PvpLiveness.RESIGN')); bytes4 constant PVP_LIVENESS_ADVANCE = bytes4(keccak256('PvpLiveness.ADVANCE')); uint8 constant PVP_LIVENESS_CAN_SKIP = 1; uint8 constant PVP_LIVENESS_CAN_FORFEIT = 2; uint8 constant PVP_LIVENESS_CAN_RESIGN = 4; uint8 constant PVP_LIVENESS_CAN_ADVANCE = 8; enum PvpWaitKind { NONE, PLAYER_ACTION, PLAYER_REVEAL, TIMED_ADVANCE, CHALLENGE_WINDOW } // `dueAt` and `forfeitAt` are enabling timestamps; zero means that path is closed for this wait. // A game with no skip path publishes `dueAt = 0`, and a wait that can never cost a player the match // publishes `forfeitAt = 0`. struct PvpWait { PvpWaitKind kind; address actor; bytes32 subject; uint64 dueAt; uint64 forfeitAt; } struct PvpLivenessStatus { uint8 supported; PvpWait[] waits; } interface IERC165 { function supportsInterface(bytes4 interfaceId) external view returns (bool); } /// @notice Detect with ERC-165 `supportsInterface(type(IPvpLiveness).interfaceId)`; treat a revert /// or `false` as "not implemented". interface IPvpLiveness { /// @notice What the lobby is waiting for, from the values every lobby read model already carries. /// `waits` is empty outside the phases that accept actions. function liveness( LobbyPhase phase, bytes calldata config, bytes calldata gameState ) external view returns (PvpLivenessStatus memory); } /// @notice Base for games adopting the standard: ERC-165, phase gating, payload parsing and wait /// resolution. The game keeps its own state and outcomes — call `_livenessAction` at the top /// of `onPlayerAction`, then `_requireLivenessAction` with the waits built from the state it /// already decoded, and apply the returned wait. abstract contract PvpLiveness is IPvpLiveness, IERC165 { enum LivenessAction { NONE, SKIP, FORFEIT, RESIGN, ADVANCE } error PvpLiveness__ActionUnsupported(LivenessAction action); error PvpLiveness__NoPendingWait(bytes32 subject); error PvpLiveness__NotDue(uint64 dueAt); function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { return interfaceId == type(IPvpLiveness).interfaceId || interfaceId == type(IERC165).interfaceId; } function liveness( LobbyPhase phase, bytes calldata config, bytes calldata gameState ) external view returns (PvpLivenessStatus memory status) { status.supported = _livenessCapabilities(); if (phase != LobbyPhase.IN_PROGRESS && phase != LobbyPhase.WAITING_PLAYER_ACTION) { return status; } status.waits = _pendingWaits(config, gameState); } function _livenessCapabilities() internal pure virtual returns (uint8); /// @dev Only called in action-accepting phases. Return an empty array when nothing is pending. function _pendingWaits( bytes calldata config, bytes calldata gameState ) internal view virtual returns (PvpWait[] memory); function _livenessAction( bytes calldata actionData ) internal pure returns (LivenessAction action, bytes32 subject) { if (actionData.length != 36) return (LivenessAction.NONE, bytes32(0)); bytes4 tag = bytes4(actionData); subject = bytes32(actionData[4:]); if (tag == PVP_LIVENESS_SKIP) return (LivenessAction.SKIP, subject); if (tag == PVP_LIVENESS_FORFEIT) return (LivenessAction.FORFEIT, subject); if (tag == PVP_LIVENESS_RESIGN) return (LivenessAction.RESIGN, subject); if (tag == PVP_LIVENESS_ADVANCE) return (LivenessAction.ADVANCE, subject); return (LivenessAction.NONE, bytes32(0)); } /// @dev Returns the wait the action unblocks; RESIGN returns an empty wait because the game, not /// the clock, decides who may resign. function _requireLivenessAction( LivenessAction action, bytes32 subject, PvpWait[] memory waits ) internal view returns (PvpWait memory wait) { uint8 capability = action == LivenessAction.SKIP ? PVP_LIVENESS_CAN_SKIP : action == LivenessAction.FORFEIT ? PVP_LIVENESS_CAN_FORFEIT : action == LivenessAction.RESIGN ? PVP_LIVENESS_CAN_RESIGN : PVP_LIVENESS_CAN_ADVANCE; if (_livenessCapabilities() & capability == 0) revert PvpLiveness__ActionUnsupported(action); if (action == LivenessAction.RESIGN) return wait; return _dueWait(waits, action, subject); } function _dueWait( PvpWait[] memory waits, LivenessAction action, bytes32 subject ) private view returns (PvpWait memory) { uint64 soonest; for (uint256 i = 0; i < waits.length; i++) { PvpWait memory candidate = waits[i]; bool actorless = candidate.actor == address(0); if (action == LivenessAction.ADVANCE ? !actorless : actorless) continue; uint64 dueAt = action == LivenessAction.FORFEIT ? candidate.forfeitAt : candidate.dueAt; if (dueAt == 0) continue; if (subject != bytes32(0)) { bytes32 key = action == LivenessAction.FORFEIT ? bytes32(uint256(uint160(candidate.actor))) : candidate.subject; if (key != subject) continue; } if (block.timestamp > dueAt) return candidate; if (soonest == 0 || dueAt < soonest) soonest = dueAt; } if (soonest != 0) revert PvpLiveness__NotDue(soonest); revert PvpLiveness__NoPendingWait(subject); } }