Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tic-tac-toe, III: the rules as resources

Part II built the gatherings: a line of cells, the eight lines a board has, and the board. None of it knows the rules of tic-tac-toe. This part adds them, and adds them the same way as everything else: as resources. Which lines a square is on, who has won and whose turn it is are each a name that answers. Then comes the one resource that changes the game, the move, and the rules are enforced there and nowhere else. It covers the ground of the sixth and seventh parts of the series Part I credits.

/// Which lines pass through `(x, y)`: a list of their names.
pub const CHECKSET: &str = "urn:iki:tutorial:ttt:checkset:{x}:{y}";

/// Who has won: `X`, `O`, [`EMPTY`] for nobody yet, or [`DRAW`].
pub const WINNER: &str = "urn:iki:tutorial:ttt:winner";

/// Whose move it is: `X` or `O`, or [`EMPTY`] once the game is over.
pub const TURN: &str = "urn:iki:tutorial:ttt:turn";

/// Play the side to move at `(x, y)` — `Sink` only.
pub const MOVE: &str = "urn:iki:tutorial:ttt:move:{x}:{y}";

/// What the winner answers when every square is played and no line is won.
pub const DRAW: &str = "draw";

Which lines a square is on

The center of the board is on four lines, a corner on three and an edge on two. That is a fact about the board, and it can be a resource of its own: the CheckSet, urn:iki:tutorial:ttt:checkset:{x}:{y}, which answers the names of the lines through a square, one per line of text.

/// The names of the lines `table` makes that pass through the cell spelled `member`
/// (`x.y`), in name order.
///
/// Read from the table, never listed by hand: a line passes through a cell when its
/// target's member list holds that cell. A string match is exact because a cell has one
/// spelling. The answer is the ALIAS names — `row:1`, not `cells:0.1,1.1,2.1` — because
/// they read as the game; each resolves to its line, and the line's name is the cache key.
/// Sorted, because a table keeps its rules most-specific-first, not in the order written.
fn lines_through<'t>(table: &'t AliasTable, member: &str) -> Vec<&'t str> {
    let line = CELLS.trim_end_matches("{list}");
    let mut names: Vec<&str> = table
        .rules()
        .iter()
        .filter(|rule| {
            rule.to()
                .strip_prefix(line)
                .is_some_and(|list| list.split(',').any(|m| m == member))
        })
        .map(|rule| rule.from())
        .collect();
    names.sort_unstable();
    names
}

/// `ttt-checkset`: the lines through `(x, y)`, one name per line of text.
///
/// A representation whose content is the names of other resources. It depends on its
/// name and a constant table and on nothing else — no sub-request, so no dependency any
/// `Sink` could cut — and `.cacheable()` is `Expiry::Never`: computed once in the life of
/// the kernel, and served from then on, whatever is played. A cell off the board is on no
/// line and answers the empty list; the cell itself is still a cell.
pub fn checkset(table: Arc<AliasTable>) -> FnEndpoint {
    FnEndpoint::new("ttt-checkset", move |inv: &Invocation<'_>| {
        let member = format!("{}.{}", coordinate(inv, "x")?, coordinate(inv, "y")?);
        let names = lines_through(&table, &member).join("\n");
        Ok(Representation::new(text_plain_utf8(), names.into_bytes()).cacheable())
    })
    .with_description(
        Description::new("ttt-checkset")
            .title("CheckSet")
            .summary(
                "The names of the lines through (x, y), one per line; none for a cell off \
                 the board.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(coordinates()[0].clone())
            .input(coordinates()[1].clone())
            .output(TEXT_PLAIN_UTF8),
    )
}

Look at what the answer is. It is not marks, and it is not a board. It is names of other resources, and each one can be resolved. A representation whose content is links is what makes data linked, and nothing about it needed a new mechanism: a list of names is text, and newline-separated text is how the ecosystem writes a list.

The list is not written anywhere by hand. The CheckSet reads Part II’s table of eight aliases and keeps each alias whose line holds the square. So “three by three” still lives in that table and nowhere else. A board of another size, or Connect Four’s sixty-nine lines, is another table, and the CheckSet would answer for it with no change of code.

It answers the alias names, row:1 rather than cells:0.1,1.1,2.1, because those read as the game. Each one resolves, through the alias, to its line, and the line’s own name is the one the cache files the answer under.

As before, one kernel serves every cell on this page, and the page starts on a fresh board. Ask the center, twice:

urn:iki:tutorial:ttt:column:1
urn:iki:tutorial:ttt:diagonal:0
urn:iki:tutorial:ttt:diagonal:1
urn:iki:tutorial:ttt:row:1
[computed]
urn:iki:tutorial:ttt:column:1
urn:iki:tutorial:ttt:diagonal:0
urn:iki:tutorial:ttt:diagonal:1
urn:iki:tutorial:ttt:row:1
[cached]

A corner, an edge, and a square off the board. The last answers the empty list. It is still a cell (Part I’s model takes any coordinates), but no line passes through it, and only the table knows where the board is:

urn:iki:tutorial:ttt:column:0
urn:iki:tutorial:ttt:diagonal:0
urn:iki:tutorial:ttt:row:0
[computed]
urn:iki:tutorial:ttt:column:1
urn:iki:tutorial:ttt:row:0
[computed]
[computed]

A platonic resource

The CheckSet depends on its own name and a constant table, and on nothing else. It issues no sub-request, so it records no dependency that a Sink could cut. Its .cacheable() is Expiry::Never, and here that is literally true: once computed, it is served for the life of the kernel, whatever is played. The series has a name for a resource like this, a platonic resource, computed once in the life of the system. The cell in Part I was platonic in a weaker sense, because it answers for every square before anyone plays one. The CheckSet is platonic in the strong one: no move can make it wrong, so none ever recomputes it.

/// The CheckSet names the lines through a cell by their alias names, and is computed once:
/// no move — not even one on that very square — un-caches it.
#[test]
fn the_checkset_is_a_list_of_names_computed_once() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let center = checkset_name(1, 1);

    assert_eq!(
        source(&kernel, &center)?,
        "urn:iki:tutorial:ttt:column:1\n\
         urn:iki:tutorial:ttt:diagonal:0\n\
         urn:iki:tutorial:ttt:diagonal:1\n\
         urn:iki:tutorial:ttt:row:1"
    );
    assert!(cached(&kernel, &center));

    play(&kernel, 1, 1)?;
    play(&kernel, 0, 0)?;
    sink(&kernel, &stored_name(1, 1), "O")?; // straight into the store, past the rules
    assert!(cached(&kernel, &center), "nothing it depends on can change");
    Ok(())
}

The end of this page checks the same thing in the page’s own kernel, after a whole game.

Who has won, and whose turn it is

/// The mark that fills `line` — every mark one kind, and not the empty cell — if one does.
fn filled_by(line: &str) -> Option<char> {
    let mut marks = line.chars();
    let first = marks.next()?;
    (first.to_string() != EMPTY && marks.all(|mark| mark == first)).then_some(first)
}

/// `ttt-winner`: `X` or `O` if a line is filled by one of them, [`DRAW`] if every square
/// is played and none is, and [`EMPTY`] while the game goes on.
///
/// A pure function of the lines, read by their alias names from the table, through the
/// kernel. It names no thread: its dependencies are the eight lines, so a move recomputes
/// the lines through its square and this, and serves the others from cache. A board no
/// game reaches — both marks with a full line, played straight into the store — answers
/// the mark of whichever full line the table lists first.
pub fn winner(table: Arc<AliasTable>) -> AsyncFnEndpoint {
    AsyncFnEndpoint::new(
        "ttt-winner",
        move |inv: &Invocation<'_>| -> InvokeFuture<'_> {
            let table = Arc::clone(&table);
            Box::pin(async move {
                let mut lines = Vec::new();
                for rule in table.rules() {
                    lines.push(text_of(inv, rule.from()).await?);
                }
                let answer = match lines.iter().find_map(|line| filled_by(line)) {
                    Some(mark) => mark.to_string(),
                    None if lines.iter().all(|line| !line.contains(EMPTY)) => DRAW.to_string(),
                    None => EMPTY.to_string(),
                };
                Ok(Representation::new(text_plain_utf8(), answer.into_bytes()).cacheable())
            })
        },
    )
    .with_description(
        Description::new("ttt-winner")
            .title("Winner")
            .summary("X or O once a line is theirs, draw on a full board, - until then.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

The winner reads the eight lines by their alias names, from the same table, through the kernel. A line is won when all three of its marks are one mark and not the empty cell. If none is won and no line has an empty cell left, the board is full, and the answer is draw. Otherwise it is -, for nobody yet.

It is a pure function of the lines, so it is .cacheable(), and like the board in Part II it names no golden thread. Its dependencies are the eight lines it read. A move reaches it through the lines that pass through the move’s square, and through nothing else.

This is where the series puts the CheckSet to work. Checking for a win after a move, it looks only at the lines through the square just played, because the other lines cannot have changed. Here the kernel does that narrowing on its own. The winner asks for all eight lines, and the ones a move did not touch answer from cache without running any code. The CheckSet is still worth having, because the move below asks it where the board is. It is just not needed to make the winner cheap.

/// `ttt-turn`: whose move it is — `X` when the marks are level (so `X` opens), `O` when
/// `X` is ahead — and [`EMPTY`] once the winner says the game is over.
///
/// A pure function of two resources, the winner and the board, and cacheable for the same
/// reason they are.
pub fn turn() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-turn", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let side = if text_of(inv, WINNER).await? != EMPTY {
                EMPTY
            } else {
                let board = text_of(inv, BOARD).await?;
                let count = |mark: char| board.chars().filter(|&c| c == mark).count();
                if count('X') <= count('O') {
                    "X"
                } else {
                    "O"
                }
            };
            Ok(Representation::new(text_plain_utf8(), side.as_bytes().to_vec()).cacheable())
        })
    })
    .with_description(
        Description::new("ttt-turn")
            .title("Turn")
            .summary("Whose move it is: X (who opens) or O; - once the game is over.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

Whose turn it is comes from the board. X opens, so X plays when the marks are level and O plays when X is one ahead. Once the winner says the game is over, whether won or drawn, nobody plays, and the turn answers -. The turn is a pure function of two resources, the winner and the board, and it is cacheable for the same reason they are.

A fresh board: nobody has won, and X is to play.

-
[computed]
X
[computed]

A draw takes nine moves to reach, so this page does not play one. The crate’s tests do: a full board with no line answers draw, the turn answers -, and a tenth move is refused.

The move: constraint at the edge

So far nothing in the game refuses anything. The stored cell takes any mark at any square, the line takes any cells, and the winner and the turn only describe what is there. That is deliberate. The rules arrive in one resource, the move, urn:iki:tutorial:ttt:move:{x}:{y}, which a player writes to and nobody reads.

/// `ttt-move`: play the side to move at `(x, y)` — the one place the rules are enforced.
///
/// Each check reads a resource the game already has, and the move itself is a `Sink` to
/// the stored cell issued through the invocation — the same request a player could make
/// by hand, and so the same cut of the stored cell's thread. Nothing about turns or boards
/// moves into the stored cell, which still takes any mark at any square.
///
/// Refused, with [`Error::InvalidArgument`]: a square no line passes through (off the
/// board), any square once the game is over, a square already played, and a `content`
/// mark that is not the side to play. `content` is optional; left out, the move plays
/// whoever's turn it is.
pub fn play_move() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-move", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            if inv.request.verb != Verb::Sink {
                return Err(Error::Endpoint(
                    "a move is played, not read: `sink` it (read the board instead)".into(),
                ));
            }
            let (x, y) = (coordinate(inv, "x")?, coordinate(inv, "y")?);
            let square = |detail: String| Error::InvalidArgument {
                name: "x, y".to_string(),
                detail,
            };
            if text_of(inv, &checkset_name(x, y)).await?.is_empty() {
                return Err(square(format!(
                    "{x},{y} is off the board — no line passes through it"
                )));
            }
            match text_of(inv, WINNER).await?.as_str() {
                won if won == EMPTY => {}
                DRAW => return Err(square("the game is over — a draw".to_string())),
                mark => return Err(square(format!("the game is over — {mark} has won"))),
            }
            let there = text_of(inv, &cell_name(x, y)).await?;
            if there != EMPTY {
                return Err(square(format!("{x},{y} is taken — {there} played there")));
            }
            let side = text_of(inv, TURN).await?;
            let offered = match inv.inline_str("content") {
                Ok(mark) => mark.trim(),
                Err(Error::MissingArgument(_)) => "",
                Err(other) => return Err(other),
            };
            if !offered.is_empty() && offered != side {
                return Err(Error::InvalidArgument {
                    name: "content".to_string(),
                    detail: format!("it is {side}'s turn, not {offered}'s"),
                });
            }
            let stored = Iri::parse(stored_name(x, y))
                .map_err(|e| Error::Endpoint(format!("the stored cell's name: {e}")))?;
            let play = Request::new(Verb::Sink, stored)
                .with_arg("content", ArgRef::Inline(side.clone().into_bytes()));
            inv.issue(play).await?;
            Ok(Representation::new(
                text_plain_utf8(),
                format!("{side} plays {x},{y}").into_bytes(),
            ))
        })
    })
    .with_description(
        Description::new("ttt-move")
            .title("Move")
            .summary(
                "Play the side to move at (x, y), if the rules allow it: on the board, the \
                 game not over, the square free, and the mark (if given) the side to play.",
            )
            .verb(Verb::Sink)
            .verb(Verb::Meta)
            .input(coordinates()[0].clone())
            .input(coordinates()[1].clone())
            .input(
                ArgSpec::new("content")
                    .summary(
                        "the mark to play — checked against the turn; left out, the turn plays",
                    )
                    .class(XSD_STRING)
                    .one_of(["X", "O"])
                    .optional(),
            )
            .output(TEXT_PLAIN_UTF8),
    )
}

The move enforces the rules by asking the resources the game already has. The CheckSet says whether the square is on the board, the winner whether the game is over, the cell whether the square is free, and the turn whose mark it is. Each refusal is an InvalidArgument with a reason a player can act on. Only when all four agree does the move play, and it plays by issuing a Sink to the stored cell, the same request a player could make by hand.

content is optional and checked. Left out, the move plays whoever’s turn it is. Named, it must be the side to play, so a player who believes it is their turn and is wrong is told so rather than silently playing for the other side.

This is the series’ seventh part in one sentence: keep the model loose, and constrain it at the boundary of the application. The stored cell does not know about turns, and the move does not change it. The atoms are still the atoms Part I built. Another game on the same cells (Connect Four, or tic-tac-toe on a bigger board) would write its own move and its own table and reuse everything else. Put the rules into the stored cell, and the store would stop being reusable.

The move can play through the stored cell because a Sink issued from inside an endpoint is still a Sink to the kernel. It cuts the thread named after its target, exactly as a Sink a player issues does, and the recompute story in Part II depends on that:

/// The move's `Sink` to the stored cell is a sub-request, and the kernel cuts the stored
/// cell's thread on it exactly as it does on a `Sink` a player issues by hand. The whole
/// recompute story rests on this.
#[test]
fn a_sink_issued_inside_the_move_cuts_the_stored_cells_thread() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (by_move, by_hand) = (cell_name(1, 1), cell_name(2, 2));
    assert_eq!(source(&kernel, &by_move)?, EMPTY);
    assert_eq!(source(&kernel, &by_hand)?, EMPTY);
    assert!(cached(&kernel, &by_move) && cached(&kernel, &by_hand));

    play(&kernel, 1, 1)?; // the Sink to stored:1:1 is issued by the move
    sink(&kernel, &stored_name(2, 2), "O")?; // issued by the caller

    assert!(!cached(&kernel, &by_move), "cut from inside the move");
    assert!(!cached(&kernel, &by_hand), "cut from outside, as in part I");
    assert_eq!(source(&kernel, &by_move)?, "X");
    assert_eq!(source(&kernel, &by_hand)?, "O");
    Ok(())
}

Play the center, with no mark named. The turn passes to O:

X plays 1,1
[uncacheable]
O
[computed]

Now each of the rules, once. Naming the wrong mark, then the right one:

error: invalid argument `content`: it is O's turn, not X's
       `content`: the mark to play — checked against the turn; left out, the turn plays (one of: X, O)
O plays 1,0
[uncacheable]

A square that is taken, and a square that is not on the board:

error: invalid argument `x, y`: 1,1 is taken — X played there
error: invalid argument `x, y`: 3,1 is off the board — no line passes through it

The move describes itself like everything else, so a player (or an agent choosing a tool) can see what it takes before it tries. content is ik:required false, and one of the two marks:

@prefix ik: <https://ikigai-rs.dev/ns#> .
 
<urn:ikigai:endpoint:ttt-move> a ik:Endpoint ;
    ik:id "ttt-move" ;
    ik:title "Move" ;
    ik:summary "Play the side to move at (x, y), if the rules allow it: on the board, the game not over, the square free, and the mark (if given) the side to play." ;
    ik:verb "Sink", "Meta" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:endpoint:ttt-move:input:x> ;
    ik:input <urn:ikigai:endpoint:ttt-move:input:y> ;
    ik:input <urn:ikigai:endpoint:ttt-move:input:content> ;
    ik:action <urn:ikigai:contract:ttt-move:sink:b3:…> .
 
<urn:ikigai:endpoint:ttt-move:input:x> ik:inputName "x" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the column — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:endpoint:ttt-move:input:y> ik:inputName "y" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the row — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:endpoint:ttt-move:input:content> ik:inputName "content" ;
    ik:source "argument" ;
    ik:required false ;
    ik:summary "the mark to play — checked against the turn; left out, the turn plays" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> ;
    ik:oneOf "X" ;
    ik:oneOf "O" .
 
<urn:ikigai:contract:ttt-move:sink:b3:…:input:x> ik:inputName "x" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the column — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-move:sink:b3:…:input:y> ik:inputName "y" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the row — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-move:sink:b3:…:input:content> ik:inputName "content" ;
    ik:source "argument" ;
    ik:required false ;
    ik:summary "the mark to play — checked against the turn; left out, the turn plays" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> ;
    ik:oneOf "X" ;
    ik:oneOf "O" .
 
<urn:ikigai:contract:ttt-move:sink:b3:…> a ik:Action ;
    ik:verb "Sink" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:contract:ttt-move:sink:b3:…:input:x> ;
    ik:input <urn:ikigai:contract:ttt-move:sink:b3:…:input:y> ;
    ik:input <urn:ikigai:contract:ttt-move:sink:b3:…:input:content> .
[computed]

One move, and what it recomputes

Part II showed that one move reaches the lines through its square and the board, and nothing else. The rules sit one level above the lines, so the same holds for them:

/// One move recomputes the lines through its square, the winner and the turn — and reads
/// exactly one stored cell to do it.
#[test]
fn one_move_recomputes_only_the_lines_through_its_square() -> Result<()> {
    let store = Arc::new(CellStore::default());
    let kernel = kernel_over(store.clone());
    play(&kernel, 1, 1)?;
    play(&kernel, 1, 0)?;
    // Everything read once, so everything is cached.
    source(&kernel, WINNER)?;
    source(&kernel, TURN)?;

    play(&kernel, 0, 0)?; // a corner: one row, one column, one diagonal
    for through in [ROW_0, COLUMN_0, DIAGONAL_0, WINNER, TURN] {
        assert!(!cached(&kernel, through), "{through} passes through (0,0)");
    }
    for past in [ROW_1, ROW_2, COLUMN_1, COLUMN_2, DIAGONAL_1] {
        assert!(cached(&kernel, past), "{past} does not");
    }
    assert!(cached(&kernel, &checkset_name(0, 0)));

    let reads = store.reads.load(Ordering::SeqCst);
    assert_eq!(source(&kernel, WINNER)?, EMPTY);
    assert_eq!(source(&kernel, TURN)?, "O");
    assert_eq!(
        store.reads.load(Ordering::SeqCst),
        reads + 1,
        "of nine stored cells, the corner alone is read again"
    );
    Ok(())
}

The refusals above already asked the winner, so it is cached, and so is every line under it:

-
[cached]

X plays the top-left corner. The corner is on row 0, column 0 and diagonal 0, so those three lines are no longer cached, and neither are the winner and the turn built on them. The probes name each line by its own name, not its alias: cache does not resolve, so it cannot see a rewrite the game’s space makes, and a probe of row:0 would under-report. Row 1 misses the corner and is still cached. So is the corner’s CheckSet, which the move itself read:

X plays 0,0
[uncacheable]
not cached
not cached
not cached
not cached
not cached
cached
cached

Now ask for the winner again, traced:

trace  urn:iki:tutorial:ttt:winner
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:ttt:winner   ttt-winner · computed · ThreadId(1) · —   → 1b  -
├─ urn:iki:tutorial:ttt:cells:0.0,1.1,2.2   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:diagonal:0 -> urn:iki:tutorial:ttt:cells:0.0,1.1,2.2
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · computed · ThreadId(1) · —
│  │  └─ urn:iki:tutorial:ttt:stored:0:0   ttt-stored · computed · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:1:1   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:2:2   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:2.0,1.1,0.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:diagonal:1 -> urn:iki:tutorial:ttt:cells:2.0,1.1,0.2
├─ urn:iki:tutorial:ttt:cells:0.0,0.1,0.2   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:0 -> urn:iki:tutorial:ttt:cells:0.0,0.1,0.2
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:0:1   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:0:2   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:1.0,1.1,1.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:1 -> urn:iki:tutorial:ttt:cells:1.0,1.1,1.2
├─ urn:iki:tutorial:ttt:cells:2.0,2.1,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:2 -> urn:iki:tutorial:ttt:cells:2.0,2.1,2.2
├─ urn:iki:tutorial:ttt:cells:0.0,1.0,2.0   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:0 -> urn:iki:tutorial:ttt:cells:0.0,1.0,2.0
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:1:0   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:2:0   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:0.1,1.1,2.1   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:1 -> urn:iki:tutorial:ttt:cells:0.1,1.1,2.1
└─ urn:iki:tutorial:ttt:cells:0.2,1.2,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:2 -> urn:iki:tutorial:ttt:cells:0.2,1.2,2.2

Read it from the top. The winner was computed, and asked for all eight lines. Three were computed and five were served. The first line to need the corner, diagonal 0, recomputed the corner’s cell, which read the store. Column 0 and row 0 then found that cell already recomputed, and cached. Of nine stored cells, one was read. That is the whole cost of a move to the rules: the lines through the square, and the one square. (The lines appear in the table’s own order, longest alias first, which is the order the winner reads them.)

The game, played out

O plays the middle of the right-hand column, which does not stop anything. X takes the bottom-right corner, completing diagonal 0. The winner says so, and the turn says nobody is to play:

O plays 2,1
[uncacheable]
X plays 2,2
[uncacheable]
X
[computed]
-
[computed]

The last rule: the game is over, so any move is refused, whatever square it names.

error: invalid argument `x, y`: the game is over — X has won
XO-
-XO
--X
[computed]

Finally, follow the links. The CheckSet answers names, and the engine’s map, .., runs a stage once for each line of its input. urn:iki:fn:compose sources the resource its src names and expands any transclusion markers in it. A line has none, so here it does exactly one thing: read the resource a name names. So this reads the four lines through the center, the winning diagonal among them:

OX-
XXX
-X-
-XO
[1 cached · 4 computed]
cached

The one cached answer in that tally is the CheckSet itself. It was computed in the first cell on this page, and after five moves, one of them on the center square itself, it is still cached, because nothing it depends on can change.

Next

Part IV plays more than one game at once, and asks where the game belongs: in every name, or around them as context.