Action unknown: copypageplugin__copy

Mit unserem Wissen über Web-APIs und JSON wollen wir nun die TicTacToe Web-App weiterentwickeln, so dass zwei Spieler:innen übers Web gegeneinander spielen können.

Dazu muss Folgendes geschehen:

  • Die Spiel-Logik muss vom Browser auf den Server wandern.
    • Dazu muss ein Server-Prozess gestartet werden, wir verwenden dafür nodejs und express.
    • Der Server muss geeignete Web-APIs zur Verfügung stellen:
      • /game/: gibt den Zustand des Games im JSON-Format zurück.
      • /play/<cellid>/<color>: Setzt im Game die Zelle <cellid> auf <color>. Gibt anschliessend den Game-Zustand zurück.
  • Das Javascript im Browser muss sich nur noch darum kümmern, die richtigen Web-APIs auf dem Server aufzurufen, und den Zustand des Spiels im HTML darzustellen.
  • Node.js installieren:
  • Projekt initialisieren:
    • Eine Eingabeaufforderung im Ordner der Webapp öffnen.
      • npm init
      • npm install express cookie-parser
    • Öffne die Datei package.json:
      • füge einen Eintrag hinzu: „type“: „module“
    • Falls du git verwendest:
      • füge node-modules zu .gitignore hinzu.

Zuerst benötigen wir eine Datei app.js, die definiert, wie der Server funktioniert, und welche APIs wir bereitstellen:

app.js
import express from 'express'
 
const app = express()
const port = 3000
 
// Hello World.
app.get('/hello', (req, res) => {
  res.send('Hello World!')
})
 
// Listen on the given port
app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
})

Mit dem Kommandozeilenaufruf node app.js wird der Node-Server gestartet. Auf der Adresse http://localhost:3000/hello können wir auf die oben definierte API zugreifen.

Der Code bedeutet: Wann immer eine Anfrage für den Pfad /hello ankommt, so sende als Response (res) den String Hello World zurück.

Es ist auch möglich, Teile der im Request (req) verlangten Adresse als Parameter zu erhalten:

app.get('/hello/:name', (req, res) => {
    let name = req.params['name']
    res.send(`Hello ${name}!`)
})

Die HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner static anlegen, und die Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern:

app.js
// Serve all files from static/ as is.
// For example, a request for '/tictactoe.html' will be served from
// 'static/tictactoe.html'
app.use(express.static('static'))

Ausprobieren: Node neu starten und den URL http://localhost:3000/tictactoe.html laden - unser HTML + CSS sollte korrekt angezeigt werden!

Erstelle eine Kopie deiner tictactoe.js Datei, um auf dem Server ein Spiel laufen zu lassen. Idealerweise ist deine Datei bereits objektorientiert, damit der Server eine Vielzahl von Spielen parallell betreuen kann.

Was muss sich ändern?

  • Der Spielzustand kann nicht mehr im HTML gespeichert werden. Es bietet sich an, ein Array von Buchstaben zu verwenden let grid = Array(9).fill(' ').
  • Der Spielzustand muss als JSON vom Server zu den Clients gesendet werden. Schreibe eine Funktion toJson(), die Code ähnlich wie den folgenden produziert:
app.js
import express from 'express'
 
const app = express()
const port = 3000
 
let game = {
    "id": 0,
    "state": {
        "progress" : "playing",
        "next": "X"
    },
    "grid": [ " ", " ", " ", " ", " ", " ", " ", " ", " " ],
};
 
app.get('/game/', (req, res) => {
    res.json(game);
});
 
// Listen on the given port
app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
})

In app.js erstellen wir das eine Spiel - in Zukunft wollen wir natürlich für verschiedene Benutzer mehrere Spiele erzeugen, aber für den Moment reicht eines. Nach dem Neustart lässt sich der Spielzustand auf http://localhost:3000/game abrufen.

Die Spieler müssen den Spielzustand auch ändern können, dafür erzeugen wir eine zusätzliche Route:

app.js
app.get('/play/:cell/:color', (req, res) => {
    // TODO: security
    let cell = req.params['cell'];
    let color = req.params['color'];
    game.grid[cell] = color;
    if (game.state.next == 'X') {
        game.state.next = 'O';
    } else {
        game.state.next = 'X';
    }
    res.json(game);
});

Nun haben wir einen spielbaren Server implementiert. Über URLs wie http://localhost:3000/play/3/X lässt sich der Spielzustand verändern. Kannst du (ganz ohne Grafik, nur mit den obigen URLs) ein Spiel spielen?

Was gibt es noch zu lösen?

  • Der Browser benötigt auch Javascript. Der Click-Handler jedes Buttons soll das play API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.
  • Wie stellen wir sicher, dass der Spielzug überhaupt vom richtigen Spieler her kommt?
  • Wie weiss der Client (Browser), ob er X oder O ist?
  • Gewinner-Erkennung?
  • Wie können mehrere Spiele betreut werden?

User Identifizierung

Die kanonische Methode, um den Benutzer oder schon nur eine Session zu identifizieren, sind Cookies. Normale Session-Cookies leben nur eine kurze Zeit (während die Webseite offen ist) und sind aus Datenschutzperspektive unproblematisch.

Der cookie-parser muss dafür importiert und in der App installiert werden.

Der folgende Code weist jeder Session eine eigene zufällige Id (z.B. 25d259e7-fd02-47b4-9377-e722d505717c) zu. Zudem wird das zurückgegebene JSON so modifiziert, dass der Eintrag color jeweils X oder O ist, je nachdem, welcher Spieler den Request auslöst. Für den Spieler, der an der Reihe ist, wird der Eintrag yourturn gesetzt. Dafür wird zuerst eine Kopie (structuredCopy) des Server-Spielzustands erstellt - die color ist ja je nach Client verschieden.

app.js
import express from 'express'
import cookieParser from 'cookie-parser'
 
const app = express()
app.use(cookieParser())
 
// ...
 
// Returns the user's userid, creating a random
// userid if there is none, storing it as a cookie.
function getUserId(req, res) {
    let userid = req.cookies.userid
    if (userid == undefined) {
        userid = crypto.randomUUID()  // random unique ID
        res.cookie('userid', userid)
    }
    return userid
}
 
// Create a copy of game to return and
// sets the 'color' and 'yourturn' entries
// according to the user id.
function sendGame(req, res) {
    // Join a game that is waiting for players.
    let userid = getUserId(req, res)
    if (game.player_X == undefined) {
        // Join game as X
        console.log(`User ${userid} joining game as X`)
        game.player_X = userid
    } else if (game.player_O == undefined && game.player_X != userid) {
        // Join game as O
        console.log(`User ${userid} joining game as O`)
        game.player_O = userid
    }
 
    // Create a copy and set the 'color' property
    let copy = structuredClone(game)
    if (game.player_X == userid) {
        // User is already assigned X
        copy.color = 'X'
    } else if (game.player_O == userid) {
        // User is already assigned O
        copy.color = 'O'
    } else {
        // game already has two other players
    }
    // Set the 'yourturn' property if applicable
    if (copy.state.next == copy.color) {
        copy.yourturn = true
    }
    res.json(copy)
}
 
app.get('/game/', (req, res) => {
    sendGame(req, res);
});

Validation

Natürlich könnte es sein, dass ein falsch-programmierter Client einen ungültigen Spielzug versucht - dies sollte abgefangen und mit einem 403 (Forbidden) HTTP-Status-Code beantwortet werden:

app.js
app.get('/play/:cell/:color', (req, res) => {
    // TODO: security
    let cell = req.params['cell'];
    let color = req.params['color'];
    if (color != game.state.next) {
        res.status(403).send("Not your turn, my friend!");
        return;
    }
    if (game.grid[cell] != " ") {
        res.status(403).send("Cell is not free, buddy!");
        return;
    }
    if (game['player_' + color] != getUserId(req, res)) {
        res.status(403).send("Trying to play for someone else?!");
        return;
    }
    game.grid[cell] = color;
    if (game.state.next == 'X') {
        game.state.next = 'O';
    } else {
        game.state.next = 'X';
    }
    sendGame(req, res);
});

Neu muss die Spiellogik nicht mehr auf dem Client passieren. Vielmehr soll jeder Knopfdruck das play API aufrufen und anschliessend den Inhalt des HTML an den Spielzustand anpassen.

Zudem sollten wir den Zustand des Spiels auch laden können, ohne einen Spielzug zu machen - das gelingt über das game API. Als erste Version reicht es, den Zustand auf Knopfdruck zu laden. Herausforderung: Der Spielzustand wird periodisch (z.B. jede Sekunde) neu geladen, solange der Gegner am Zug ist.

Idee:

Der Click-Handler jedes Buttons soll das play API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.

tictactoe_client.js
// Make a play
async function play(cell) {
    response = await fetch(`/play/${cell}/${game.color}`);
    await updateGame(response)
    poll();
}
 
// Set up click handlers and start polling.
async function init() {
    poll();
 
    let i = 0;
    for (const button of document.querySelectorAll(".grid button")) {
        const cell = i;
        button.addEventListener('click', () => play(cell));
        i++;
    }
}
 
init()

Bleibt nur noch, das HTML an das geänderte Game anzupassen:

tictactoe_client.js
let game = {
    "state": {
        "progress" : "waiting",
    },
    "grid": [ " ", " ", " ", " ", " ", " ", " ", " ", " " ],
};
 
/**
 * Updates the game state from the JSON response received from a remote HTTP endpoint.
 *
 * @param {Response} response the HTTP response from a remote JSON endpoint.
 */
async function updateGame(response) {
    if (!response.ok) {
        let error =  await response.text();
        console.log("Error: " + error);
        return;
    }
    game = await response.json();
    updateHtml(game);
}
 
// Updates the HTML to match the game state given as JSON.
function updateHtml(json) {
    let i = 0;  
 
    for (const button of document.querySelectorAll(".grid button")) {
        const cellText = json.grid[i];
        // Set the data-state attribute which drives CSS formatting.
        button.setAttribute('data-state', cellText);
        // Set the text contents of the cell.
        button.textContent = cellText;
        i++;
    }
}
  • ef_informatik/webapps/server.txt
  • Zuletzt geändert: 2024-09-18 14:58
  • von hof