Commit fdf5bd06 authored by Adrian Sutton's avatar Adrian Sutton Committed by GitHub

op-challenger: Adjust honest actor behaviour to counter freeloaders. (#9640)

* op-challenger: Only counter claims that are either the child of an honest claim or are a sibling to the left of an honest move.

* op-challenger: Stop evaluating claims if an error is encountered since we will not be tracking the honest actions.
parent 5a9c68a2
......@@ -2,7 +2,6 @@ package solver
import (
"context"
"errors"
"fmt"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
......@@ -28,42 +27,43 @@ func (s *GameSolver) CalculateNextActions(ctx context.Context, game types.Game)
if err != nil {
return nil, fmt.Errorf("failed to determine if root claim is correct: %w", err)
}
var errs []error
var actions []types.Action
agreedClaims := newHonestClaimTracker()
if agreeWithRootClaim {
agreedClaims.AddHonestClaim(types.Claim{}, game.Claims()[0])
}
for _, claim := range game.Claims() {
var action *types.Action
var err error
if claim.Depth() == game.MaxDepth() {
action, err = s.calculateStep(ctx, game, agreeWithRootClaim, claim)
action, err = s.calculateStep(ctx, game, claim, agreedClaims)
} else {
action, err = s.calculateMove(ctx, game, agreeWithRootClaim, claim)
action, err = s.calculateMove(ctx, game, claim, agreedClaims)
}
if err != nil {
errs = append(errs, err)
continue
// Unable to continue iterating claims safely because we may not have tracked the required honest moves
// for this claim which affects the response to later claims.
// Any actions we've already identified are still safe to apply.
return actions, fmt.Errorf("failed to determine response to claim %v: %w", claim.ContractIndex, err)
}
if action == nil {
continue
}
actions = append(actions, *action)
}
return actions, errors.Join(errs...)
return actions, nil
}
func (s *GameSolver) calculateStep(ctx context.Context, game types.Game, agreeWithRootClaim bool, claim types.Claim) (*types.Action, error) {
func (s *GameSolver) calculateStep(ctx context.Context, game types.Game, claim types.Claim, agreedClaims *honestClaimTracker) (*types.Action, error) {
if claim.CounteredBy != (common.Address{}) {
return nil, nil
}
if game.AgreeWithClaimLevel(claim, agreeWithRootClaim) {
return nil, nil
}
step, err := s.claimSolver.AttemptStep(ctx, game, claim)
if errors.Is(err, ErrStepIgnoreInvalidPath) {
return nil, nil
}
step, err := s.claimSolver.AttemptStep(ctx, game, claim, agreedClaims)
if err != nil {
return nil, err
}
if step == nil {
return nil, nil
}
return &types.Action{
Type: types.ActionTypeStep,
ParentIdx: step.LeafClaim.ContractIndex,
......@@ -75,15 +75,16 @@ func (s *GameSolver) calculateStep(ctx context.Context, game types.Game, agreeWi
}, nil
}
func (s *GameSolver) calculateMove(ctx context.Context, game types.Game, agreeWithRootClaim bool, claim types.Claim) (*types.Action, error) {
if game.AgreeWithClaimLevel(claim, agreeWithRootClaim) {
return nil, nil
}
move, err := s.claimSolver.NextMove(ctx, claim, game)
func (s *GameSolver) calculateMove(ctx context.Context, game types.Game, claim types.Claim, honestClaims *honestClaimTracker) (*types.Action, error) {
move, err := s.claimSolver.NextMove(ctx, claim, game, honestClaims)
if err != nil {
return nil, fmt.Errorf("failed to calculate next move for claim index %v: %w", claim.ContractIndex, err)
}
if move == nil || game.IsDuplicate(*move) {
if move == nil {
return nil, nil
}
honestClaims.AddHonestClaim(claim, *move)
if game.IsDuplicate(*move) {
return nil, nil
}
return &types.Action{
......
......@@ -14,16 +14,6 @@ import (
"github.com/stretchr/testify/require"
)
const expectFreeloaderCounters = false
type RunCondition uint8
const (
RunAlways RunCondition = iota
RunFreeloadersCountered
RunFreeloadersNotCountered
)
func TestCalculateNextActions(t *testing.T) {
maxDepth := types.Depth(6)
startingL2BlockNumber := big.NewInt(0)
......@@ -33,7 +23,6 @@ func TestCalculateNextActions(t *testing.T) {
name string
rootClaimCorrect bool
setupGame func(builder *faulttest.GameBuilder)
runCondition RunCondition
}{
{
name: "AttackRootClaim",
......@@ -106,7 +95,6 @@ func TestCalculateNextActions(t *testing.T) {
Defend().ExpectDefend(). // Defender agrees at this point, we should defend
Attack().ExpectDefend() // Freeloader attacks instead of defends
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-InvalidClaimAtInvalidAttackPosition",
......@@ -116,7 +104,6 @@ func TestCalculateNextActions(t *testing.T) {
Defend().ExpectDefend(). // Defender agrees at this point, we should defend
Attack(faulttest.WithValue(common.Hash{0xbb})).ExpectAttack() // Freeloader attacks with wrong claim instead of defends
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-InvalidClaimAtValidDefensePosition",
......@@ -126,7 +113,6 @@ func TestCalculateNextActions(t *testing.T) {
Defend().ExpectDefend(). // Defender agrees at this point, we should defend
Defend(faulttest.WithValue(common.Hash{0xbb})).ExpectAttack() // Freeloader defends with wrong claim, we should attack
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-InvalidClaimAtValidAttackPosition",
......@@ -136,7 +122,6 @@ func TestCalculateNextActions(t *testing.T) {
Defend(faulttest.WithValue(common.Hash{0xaa})).ExpectAttack(). // Defender disagrees at this point, we should attack
Attack(faulttest.WithValue(common.Hash{0xbb})).ExpectAttack() // Freeloader attacks with wrong claim instead of defends
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-InvalidClaimAtInvalidDefensePosition",
......@@ -155,7 +140,6 @@ func TestCalculateNextActions(t *testing.T) {
Attack().ExpectDefend(). // Defender attacks with correct value, we should defend
Attack().ExpectDefend() // Freeloader attacks with wrong claim, we should defend
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-DoNotCounterOwnClaim",
......@@ -166,7 +150,6 @@ func TestCalculateNextActions(t *testing.T) {
Attack(). // Freeloader attacks instead, we should defend
Defend() // We do defend and we shouldn't counter our own claim
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-ContinueDefendingAgainstFreeloader",
......@@ -179,7 +162,6 @@ func TestCalculateNextActions(t *testing.T) {
Attack(faulttest.WithValue(common.Hash{0xaa})). // freeloader attacks our defense, we should attack
ExpectAttack()
},
runCondition: RunFreeloadersCountered,
},
{
name: "Freeloader-FreeloaderCountersRootClaim",
......@@ -189,14 +171,12 @@ func TestCalculateNextActions(t *testing.T) {
Attack(faulttest.WithValue(common.Hash{0xaa})). // freeloader
ExpectAttack() // Honest response to freeloader
},
runCondition: RunFreeloadersCountered,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
enforceRunConditions(t, test.runCondition)
builder := claimBuilder.GameBuilder(faulttest.WithInvalidValue(!test.rootClaimCorrect))
test.setupGame(builder)
game := builder.Game
......@@ -238,7 +218,6 @@ func TestMultipleRounds(t *testing.T) {
tests := []struct {
name string
actor actor
runCondition RunCondition
}{
{
name: "SingleRoot",
......@@ -279,7 +258,6 @@ func TestMultipleRounds(t *testing.T) {
{
name: "AttackEverythingCorrect",
actor: attackEverythingCorrect,
runCondition: RunFreeloadersCountered,
},
{
name: "DefendEverythingCorrect",
......@@ -296,9 +274,6 @@ func TestMultipleRounds(t *testing.T) {
{
name: "Exhaustive",
actor: exhaustive,
// TODO(client-pod#611): We attempt to step even though the prestate is invalid
// The step call would fail to estimate gas so not even send, but the challenger shouldn't try
runCondition: RunFreeloadersCountered,
},
}
for _, test := range tests {
......@@ -307,7 +282,6 @@ func TestMultipleRounds(t *testing.T) {
rootClaimCorrect := rootClaimCorrect
t.Run(fmt.Sprintf("%v-%v", test.name, rootClaimCorrect), func(t *testing.T) {
t.Parallel()
enforceRunConditions(t, test.runCondition)
maxDepth := types.Depth(6)
startingL2BlockNumber := big.NewInt(50)
......@@ -365,17 +339,3 @@ func applyActions(game types.Game, claimant common.Address, actions []types.Acti
}
return types.NewGameState(claims, game.MaxDepth())
}
func enforceRunConditions(t *testing.T, runCondition RunCondition) {
switch runCondition {
case RunAlways:
case RunFreeloadersCountered:
if !expectFreeloaderCounters {
t.Skip("Freeloader countering not enabled")
}
case RunFreeloadersNotCountered:
if expectFreeloaderCounters {
t.Skip("Freeloader countering enabled")
}
}
}
package solver
import "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
type honestClaimTracker struct {
// agreed tracks the existing claims in the game that the honest actor would make
// The claims may not yet have been made so are tracked by ClaimID not ContractIndex
agreed map[types.ClaimID]bool
// counters tracks the counter claim for a claim by contract index.
// The counter claim may not yet be part of the game state (ie it may be a move the honest actor is planning to make)
counters map[types.ClaimID]types.Claim
}
func newHonestClaimTracker() *honestClaimTracker {
return &honestClaimTracker{
agreed: make(map[types.ClaimID]bool),
counters: make(map[types.ClaimID]types.Claim),
}
}
func (a *honestClaimTracker) AddHonestClaim(parent types.Claim, claim types.Claim) {
a.agreed[claim.ID()] = true
if parent != (types.Claim{}) {
a.counters[parent.ID()] = claim
}
}
func (a *honestClaimTracker) IsHonest(claim types.Claim) bool {
return a.agreed[claim.ID()]
}
func (a *honestClaimTracker) HonestCounter(parent types.Claim) (types.Claim, bool) {
counter, ok := a.counters[parent.ID()]
return counter, ok
}
package solver
import (
"math/big"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/test"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/stretchr/testify/require"
)
func TestHonestClaimTracker_RootClaim(t *testing.T) {
tracker := newHonestClaimTracker()
builder := test.NewAlphabetClaimBuilder(t, big.NewInt(3), 4)
claim := builder.Seq().Get()
require.False(t, tracker.IsHonest(claim))
tracker.AddHonestClaim(types.Claim{}, claim)
require.True(t, tracker.IsHonest(claim))
}
func TestHonestClaimTracker_ChildClaim(t *testing.T) {
tracker := newHonestClaimTracker()
builder := test.NewAlphabetClaimBuilder(t, big.NewInt(3), 4)
seq := builder.Seq().Attack().Defend()
parent := seq.Get()
child := seq.Attack().Get()
require.Zero(t, child.ContractIndex, "should work for claims that are not in the game state yet")
tracker.AddHonestClaim(parent, child)
require.False(t, tracker.IsHonest(parent))
require.True(t, tracker.IsHonest(child))
counter, ok := tracker.HonestCounter(parent)
require.True(t, ok)
require.Equal(t, child, counter)
}
......@@ -11,8 +11,6 @@ import (
var (
ErrStepNonLeafNode = errors.New("cannot step on non-leaf claims")
ErrStepAgreedClaim = errors.New("cannot step on claims we agree with")
ErrStepIgnoreInvalidPath = errors.New("cannot step on claims that dispute invalid paths")
)
// claimSolver uses a [TraceProvider] to determine the moves to make in a dispute game.
......@@ -29,34 +27,54 @@ func newClaimSolver(gameDepth types.Depth, trace types.TraceAccessor) *claimSolv
}
}
// NextMove returns the next move to make given the current state of the game.
func (s *claimSolver) NextMove(ctx context.Context, claim types.Claim, game types.Game) (*types.Claim, error) {
if claim.Depth() == s.gameDepth {
return nil, types.ErrGameDepthReached
func (s *claimSolver) shouldCounter(game types.Game, claim types.Claim, honestClaims *honestClaimTracker) (bool, error) {
// Do not counter honest claims
if honestClaims.IsHonest(claim) {
return false, nil
}
if claim.IsRoot() {
// Always counter the root claim if it is not honest
return true, nil
}
// Before challenging this claim, first check that the move wasn't warranted.
// If the parent claim is on a dishonest path, then we would have moved against it anyways. So we don't move.
// Avoiding dishonest paths ensures that there's always a valid claim available to support ours during step.
if !claim.IsRoot() {
parent, err := game.GetParent(claim)
if err != nil {
return nil, err
return false, fmt.Errorf("no parent for claim %v: %w", claim.ContractIndex, err)
}
agreeWithParent, err := s.agreeWithClaimPath(ctx, game, parent)
if err != nil {
return nil, err
// Counter all claims that are countering an honest claim
if honestClaims.IsHonest(parent) {
return true, nil
}
if !agreeWithParent {
return nil, nil
counter, hasCounter := honestClaims.HonestCounter(parent)
// Do not respond to any claim countering a claim the honest actor ignored
if !hasCounter {
return false, nil
}
// Do not counter sibling to an honest claim that are right of the honest claim.
honestIdx := counter.TraceIndex(game.MaxDepth())
claimIdx := claim.TraceIndex(game.MaxDepth())
return claimIdx.Cmp(honestIdx) <= 0, nil
}
// NextMove returns the next move to make given the current state of the game.
func (s *claimSolver) NextMove(ctx context.Context, claim types.Claim, game types.Game, honestClaims *honestClaimTracker) (*types.Claim, error) {
if claim.Depth() == s.gameDepth {
return nil, types.ErrGameDepthReached
}
agree, err := s.agreeWithClaim(ctx, game, claim)
if err != nil {
return nil, err
if counter, err := s.shouldCounter(game, claim, honestClaims); err != nil {
return nil, fmt.Errorf("failed to determine if claim should be countered: %w", err)
} else if !counter {
return nil, nil
}
if agree {
if agree, err := s.agreeWithClaim(ctx, game, claim); err != nil {
return nil, err
} else if agree {
return s.defend(ctx, game, claim)
} else {
return s.attack(ctx, game, claim)
......@@ -71,30 +89,23 @@ type StepData struct {
OracleData *types.PreimageOracleData
}
// AttemptStep determines what step should occur for a given leaf claim.
// AttemptStep determines what step, if any, should occur for a given leaf claim.
// An error will be returned if the claim is not at the max depth.
// Returns ErrStepIgnoreInvalidPath if the claim disputes an invalid path
func (s *claimSolver) AttemptStep(ctx context.Context, game types.Game, claim types.Claim) (StepData, error) {
// Returns nil, nil if no step should be performed.
func (s *claimSolver) AttemptStep(ctx context.Context, game types.Game, claim types.Claim, honestClaims *honestClaimTracker) (*StepData, error) {
if claim.Depth() != s.gameDepth {
return StepData{}, ErrStepNonLeafNode
return nil, ErrStepNonLeafNode
}
// Step only on claims that dispute a valid path
parent, err := game.GetParent(claim)
if err != nil {
return StepData{}, err
}
parentValid, err := s.agreeWithClaimPath(ctx, game, parent)
if err != nil {
return StepData{}, err
}
if !parentValid {
return StepData{}, ErrStepIgnoreInvalidPath
if counter, err := s.shouldCounter(game, claim, honestClaims); err != nil {
return nil, fmt.Errorf("failed to determine if claim should be countered: %w", err)
} else if !counter {
return nil, nil
}
claimCorrect, err := s.agreeWithClaim(ctx, game, claim)
if err != nil {
return StepData{}, err
return nil, err
}
var position types.Position
......@@ -109,10 +120,10 @@ func (s *claimSolver) AttemptStep(ctx context.Context, game types.Game, claim ty
preState, proofData, oracleData, err := s.trace.GetStepData(ctx, game, claim, position)
if err != nil {
return StepData{}, err
return nil, err
}
return StepData{
return &StepData{
LeafClaim: claim,
IsAttack: !claimCorrect,
PreState: preState,
......@@ -155,29 +166,3 @@ func (s *claimSolver) agreeWithClaim(ctx context.Context, game types.Game, claim
ourValue, err := s.trace.Get(ctx, game, claim, claim.Position)
return bytes.Equal(ourValue[:], claim.Value[:]), err
}
// agreeWithClaimPath returns true if the every other claim in the path to root is correct according to the internal [TraceProvider].
func (s *claimSolver) agreeWithClaimPath(ctx context.Context, game types.Game, claim types.Claim) (bool, error) {
agree, err := s.agreeWithClaim(ctx, game, claim)
if err != nil {
return false, err
}
if !agree {
return false, nil
}
if claim.IsRoot() {
return true, nil
}
parent, err := game.GetParent(claim)
if err != nil {
return false, fmt.Errorf("failed to get parent of claim %v: %w", claim.ContractIndex, err)
}
if parent.IsRoot() {
return true, nil
}
grandParent, err := game.GetParent(parent)
if err != nil {
return false, err
}
return s.agreeWithClaimPath(ctx, game, grandParent)
}
......@@ -27,6 +27,7 @@ func TestAttemptStep(t *testing.T) {
name string
agreeWithOutputRoot bool
expectedErr error
expectNoStep bool
expectAttack bool
expectPreState []byte
expectProofData []byte
......@@ -127,7 +128,7 @@ func TestAttemptStep(t *testing.T) {
Attack(faulttest.WithValue(common.Hash{0xaa})).
Attack()
},
expectedErr: ErrStepIgnoreInvalidPath,
expectNoStep: true,
agreeWithOutputRoot: true,
},
{
......@@ -138,7 +139,7 @@ func TestAttemptStep(t *testing.T) {
Attack(faulttest.WithValue(common.Hash{0xbb})).
Attack(faulttest.WithValue(common.Hash{0xcc}))
},
expectedErr: ErrStepIgnoreInvalidPath,
expectNoStep: true,
agreeWithOutputRoot: true,
},
{
......@@ -153,7 +154,7 @@ func TestAttemptStep(t *testing.T) {
Defend().
Defend()
},
expectedErr: ErrStepIgnoreInvalidPath,
expectNoStep: true,
agreeWithOutputRoot: true,
},
}
......@@ -167,9 +168,19 @@ func TestAttemptStep(t *testing.T) {
game := builder.Game
claims := game.Claims()
lastClaim := claims[len(claims)-1]
step, err := alphabetSolver.AttemptStep(ctx, game, lastClaim)
if tableTest.expectedErr == nil {
require.NoError(t, err)
agreedClaims := newHonestClaimTracker()
if tableTest.agreeWithOutputRoot {
agreedClaims.AddHonestClaim(types.Claim{}, claims[0])
}
if (lastClaim.Depth()%2 == 0) == tableTest.agreeWithOutputRoot {
parentClaim := claims[lastClaim.ParentContractIndex]
grandParentClaim := claims[parentClaim.ParentContractIndex]
agreedClaims.AddHonestClaim(grandParentClaim, parentClaim)
}
step, err := alphabetSolver.AttemptStep(ctx, game, lastClaim, agreedClaims)
require.ErrorIs(t, err, tableTest.expectedErr)
if !tableTest.expectNoStep && tableTest.expectedErr == nil {
require.NotNil(t, step)
require.Equal(t, lastClaim, step.LeafClaim)
require.Equal(t, tableTest.expectAttack, step.IsAttack)
require.Equal(t, tableTest.expectPreState, step.PreState)
......@@ -179,8 +190,7 @@ func TestAttemptStep(t *testing.T) {
require.Equal(t, tableTest.expectedOracleData.GetPreimageWithSize(), step.OracleData.GetPreimageWithSize())
require.Equal(t, tableTest.expectedOracleData.OracleOffset, step.OracleData.OracleOffset)
} else {
require.ErrorIs(t, err, tableTest.expectedErr)
require.Equal(t, StepData{}, step)
require.Nil(t, step)
}
})
}
......
......@@ -3,9 +3,6 @@ package types
import (
"errors"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)
var (
......@@ -39,31 +36,21 @@ type Game interface {
AncestorWithTraceIndex(claim Claim, idx *big.Int) (Claim, bool)
}
type claimID common.Hash
func computeClaimID(claim Claim) claimID {
return claimID(crypto.Keccak256Hash(
claim.Position.ToGIndex().Bytes(),
claim.Value.Bytes(),
big.NewInt(int64(claim.ParentContractIndex)).Bytes(),
))
}
// gameState is a struct that represents the state of a dispute game.
// The game state implements the [Game] interface.
type gameState struct {
// claims is the list of claims in the same order as the contract
claims []Claim
claimIDs map[claimID]bool
claimIDs map[ClaimID]bool
depth Depth
}
// NewGameState returns a new game state.
// The provided [Claim] is used as the root node.
func NewGameState(claims []Claim, depth Depth) *gameState {
claimIDs := make(map[claimID]bool)
claimIDs := make(map[ClaimID]bool)
for _, claim := range claims {
claimIDs[computeClaimID(claim)] = true
claimIDs[claim.ID()] = true
}
return &gameState{
claims: claims,
......@@ -85,7 +72,7 @@ func (g *gameState) AgreeWithClaimLevel(claim Claim, agreeWithRootClaim bool) bo
}
func (g *gameState) IsDuplicate(claim Claim) bool {
return g.claimIDs[computeClaimID(claim)]
return g.claimIDs[claim.ID()]
}
func (g *gameState) Claims() []Claim {
......
......@@ -8,14 +8,11 @@ import (
preimage "github.com/ethereum-optimism/optimism/op-preimage"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)
var (
ErrGameDepthReached = errors.New("game depth reached")
// NoLocalContext is the LocalContext value used when the cannon trace provider is used alone instead of as part
// of a split game.
NoLocalContext = common.Hash{}
)
const (
......@@ -134,6 +131,8 @@ func (c *ClaimData) ValueBytes() [32]byte {
return responseArr
}
type ClaimID common.Hash
// Claim extends ClaimData with information about the relationship between two claims.
// It uses ClaimData to break cyclicity without using pointers.
// If the position of the game is Depth 0, IndexAtDepth 0 it is the root claim
......@@ -153,6 +152,14 @@ type Claim struct {
ParentContractIndex int
}
func (c Claim) ID() ClaimID {
return ClaimID(crypto.Keccak256Hash(
c.Position.ToGIndex().Bytes(),
c.Value.Bytes(),
big.NewInt(int64(c.ParentContractIndex)).Bytes(),
))
}
// IsRoot returns true if this claim is the root claim.
func (c *Claim) IsRoot() bool {
return c.Position.IsRootPosition()
......
......@@ -103,6 +103,8 @@ func TestOutputAlphabetGame_ValidOutputRoot(t *testing.T) {
}
func TestChallengerCompleteExhaustiveDisputeGame(t *testing.T) {
// TODO(client-pod#103): Update ExhaustDishonestClaims to not fail if claim it tried to post exists
t.Skip("Challenger performs many more moves now creating conflicts")
op_e2e.InitParallel(t)
testCase := func(t *testing.T, isRootCorrect bool) {
......@@ -170,8 +172,6 @@ func TestChallengerCompleteExhaustiveDisputeGame(t *testing.T) {
}
func TestOutputAlphabetGame_FreeloaderEarnsNothing(t *testing.T) {
t.Skip("CLI-103")
op_e2e.InitParallel(t)
ctx := context.Background()
sys, l1Client := startFaultDisputeSystem(t)
......@@ -218,8 +218,12 @@ func TestOutputAlphabetGame_FreeloaderEarnsNothing(t *testing.T) {
freeloaders = append(freeloaders, dishonest.DefendWithTransactOpts(ctx, common.Hash{0x05}, freeloaderOpts))
for _, freeloader := range freeloaders {
if freeloader.IsMaxDepth(ctx) {
freeloader.WaitForCountered(ctx)
} else {
freeloader.WaitForCounterClaim(ctx)
}
}
game.LogGameData(ctx)
sys.TimeTravelClock.AdvanceTime(game.GameDuration(ctx))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment