Днес ще напишем игра Tic-Tac-Toe, използвайки сървлети и JSP.

Този проект ще бъде малко по-различен от предишните. Той ще съдържа не само задачи, но и обяснения How да ги изпълнявате. Тоест, това ще бъде проект от поредицата "КАК ДА ...".

Инструкция:

  1. Форк от хранorщето: https://github.com/CodeGymCC/project-servlet.git
  2. Изтеглете вашата version на проекта на вашия компютър.
  3. Настройте стартирането на приложението в IDEA:
    • Alt + Shift + F9 -> Редактиране на конфигурации… -> Alt + вмъкване -> tom (в лентата за търсене) -> Local.
    • След това трябва да кликнете върху „КОНФИГУРИРАНЕ“ и да посочите къде е изтеглен и разопакован архивът с Tomcat.
    • В раздела „Разгръщане“: Alt + вмъкване -> Артефакт… -> tic-tac-toe:war exploded -> OK.
    • В полето „Контекст на приложението“: оставете само „/“ (наклонена черта).
    • Натиснете "Приложи".
    • Затворете прозореца с настройки.
    • Направете първото пробно изпълнение на персонализираната конфигурация. Ако всичко е напequalsо правилно, ще се отвори вашият браузър по подразбиране, в който ще бъде:
  4. Отворете file "pom.xml" . В блока „зависимости“ има 2 зависимости .
    • javax.servlet-apiотговаря за спецификацията на сървлетите. Обхватът „предоставен“ е необходим по време на разработката, но не е необходим по време на изпълнение (Tomcat вече има тази зависимост в папката lib).
    • jstl– може да се разглежда като шаблонна машина.
  5. Има 3 file в папката „webapp“ :
    • index.jsp- това е нашият шаблон (подобно на HTML pageта). Той ще съдържа маркиране и скриптове. Това е файлът, наречен „индекс“ , който се дава като начална page, ако няма конфигурации, които видяхме в стъпка 3.
    • /static/main.css- файл за стилове. Както и в предишния проект, тук всичко зависи от вас, рисувайте Howто желаете.
    • /static/jquery-3.6.0.min.js- зависимост от интерфейса, която нашият сървър ще разпространява като статичен.
  6. Пакетът "com.tictactoe" ще съдържа целия Java code. В момента има 2 класа:
    • Sign- enum, който отговаря за "кръст / нула / празнота" .
    • Fieldе нашето поле. Този клас има карта на "полето" . Принципът на съхранение на данни ще бъде следният: клетките на полето tic-tac-toe са номерирани от нула. В първия ред 0, 1 и 2. Във втория: 3, 4 и 5. И така нататък. Има и 3 метода. “getEmptyFieldIndex” търси първата празна клетка (да, опонентът ни няма да е много умен). "checkWin" проверява дали играта е приключила. Ако има ред от три кръстчета, връща кръстче; ако има ред от три нули, връща нула. В противен случай е празно. "getFieldData" - връща стойностите на картата "field" като списък, сортиран във възходящ ред на индекса.
  7. Обясненията за шаблона приключиха, сега можете да започнете задачата. Нека започнем с начертаване на table 3 на 3. За да направите това, добавете следния code към “index.jsp” :
    <table>
    	<tr>
    		<td>0</td>
    		<td>1</td>
    		<td>2</td>
    	</tr>
    	<tr>
    		<td>3</td>
    		<td>4</td>
    		<td>5</td>
    	</tr>
    	<tr>
    		<td>6</td>
    		<td>7</td>
    		<td>8</td>
    	</tr>
    </table>
    След това ще премахнем числата в tableта и ще ги заменим с кръстче, нула or празно поле. Освен това в тага „head“ включете стиловия файл. За да направите това, добавете ред:<link href="static/main.css" rel="stylesheet">

    Съдържанието на стиловия файл зависи от вас. Използвах този:
    td {
        border: 3px solid black;
        padding: 10px;
        border-collapse: separate;
        margin: 10px;
        width: 100px;
        height: 100px;
        font-size: 50px;
        text-align: center;
        empty-cells: show;
    }
    След стартиране резултатът ми изглежда така:
  8. Сега нека добавим следната функционалност: когато се щракне върху клетка, ще бъде изпратена заявка до сървъра, в която ще предадем индекса на клетката, която е щракната, като параметър. Тази задача може да бъде разделена на две части: изпращане на заявка отпред, приемане на заявка на сървъра. Да започнем отпред за промяна.

    Нека добавим параметър "onclick" към всеки таг "d" . В стойността посочваме промяната на текущата page към посочения URL address. Сървлетът, който ще отговаря за логиката, ще има URL “/logic” . И ще приеме параметър, наречен „щракване“ . Така че ще предадем индекса на клетката, върху която потребителят е щракнал.
    <table>
        <tr>
            <td onclick="window.location='/logic?click=0'">0</td>
            <td onclick="window.location='/logic?click=1'">1</td>
            <td onclick="window.location='/logic?click=2'">2</td>
        </tr>
        <tr>
            <td onclick="window.location='/logic?click=3'">3</td>
            <td onclick="window.location='/logic?click=4'">4</td>
            <td onclick="window.location='/logic?click=5'">5</td>
        </tr>
        <tr>
            <td onclick="window.location='/logic?click=6'">6</td>
            <td onclick="window.location='/logic?click=7'">7</td>
            <td onclick="window.location='/logic?click=8'">8</td>
        </tr>
    </table>
    Можете да проверите дали всичко е напequalsо правилно чрез панела за програмисти в браузъра. Например в Chrome се отваря с бутона F12 . В резултат на щракване върху клетка с индекс 4, картината ще бъде следната: Получаваме грешка, защото все още не сме създали сървлет, който може да изпрати сървъра на address „логика“ .
  9. В пакета "com.tictactoe" създайте клас "LogicServlet" , който трябва да бъде извлечен от класа "javax.servlet.http.HttpServlet" . В класа заменете метода „doGet“ .

    И нека добавим метод, който ще получи индекса на клетката, върху която е щракнато. Трябва също да добавите съпоставяне (addressът, на който този сървлет ще прихване заявката). Предлагам да направите това чрез анотация (но ако харесвате затруднения, можете също да използвате web.xml). Общ code на сървлета:
    package com.tictactoe;
    
    import javax.servlet.ServletException;
    import javax.servlet.annotation.WebServlet;
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    
    
    @WebServlet(name = "LogicServlet", value = "/logic")
    public class LogicServlet extends HttpServlet {
        @Override
    	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            int index = getSelectedIndex(req);
            resp.sendRedirect("/index.jsp");
        }
    
    
        private int getSelectedIndex(HttpServletRequest request) {
            String click = request.getParameter("click");
            boolean isNumeric = click.chars().allMatch(Character::isDigit);
            return isNumeric ? Integer.parseInt(click) : 0;
        }
    
    }
    Сега, когато щракнем върху която и да е клетка, ще получим индекса на тази клетка на сървъра (можете да се уверите, като стартирате сървъра в debug). И ще има пренасочване към същата page, от която е напequals кликът.
  10. Сега можем да кликнем, но все още не е игра. За да има логика в играта, трябва да запазите състоянието на играта (къде са кръстчетата, къде са нулите) между заявките. Най-лесният начин да направите това е да съхраните тези данни в сесията. При този подход сесията ще се съхранява на сървъра и клиентът ще получи идентификатор на сесия в бисквитка с име „JSESSIONID“ . Но не е необходимо сесията да се създава всеки път, а само в началото на играта. Нека стартираме друг сервлет за това, който ще наречем "InitServlet" . Ще заменим метода „doGet“ в него , в който ще създадем нова сесия, ще създадем поле за игра, ще поставим това поле за игра и списък от тип Sign в атрибутите на сесията и ще изпратим „ forward“ към index.jsp page. Код:
    package com.tictactoe;
    
    import javax.servlet.ServletException;
    import javax.servlet.annotation.WebServlet;
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    import java.io.IOException;
    import java.util.List;
    import java.util.Map;
    
    @WebServlet(name = "InitServlet", value = "/start")
    public class InitServlet extends HttpServlet {
        @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            // Create a new session
            HttpSession currentSession = req.getSession(true);
    
            // Create a playing field
            Field field = new Field();
            Map<Integer, Sign> fieldData = field.getField();
    
            // Get a list of field values
            List<Sign> data = field.getFieldData();
    
            // Adding field parameters to the session (needed to store state between requests)
            currentSession.setAttribute("field", field);
            // and field values ​​sorted by index (required for drawing crosses and zeroes)
            currentSession.setAttribute("data", data);
    
            // Redirect request to index.jsp page via server
            getServletContext().getRequestDispatcher("/index.jsp").forward(req, resp);
        }
    }
    И да не забравяме, нека променим началната page, която се отваря в браузъра след стартиране на сървъра, на “/start” : Сега, след като рестартирате сървъра и щракнете върху която и да е клетка от полето в менюто за разработчици на браузъра в секцията “Заглавки на заявки” , ще има бисквитка с ID на сесията:
  11. Когато имаме хранorще, в което можем да съхраняваме състояние между заявки от клиента (браузър), можем да започнем да пишем логика на играта. Логиката, която имаме, е в “LogicServlet” . Трябва да работим с метода „doGet“ . Нека добавим това поведение към метода:
    • ще получим обекта „field“ от типа Field от сесията (ще го изведем към метода „extractField“ ).
    • поставете кръст там, където потребителят е щракнал (засега без ниHowви проверки).
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // Get the current session
        HttpSession currentSession = req.getSession();
    
        // Get the playfield object from the session
        Field field = extractField(currentSession);
    
        // get the index of the cell that was clicked
        int index = getSelectedIndex(req);
    
        // put a cross in the cell that the user clicked on
        field.getField().put(index, Sign.CROSS);
    
        // Read the list of icons
        List<Sign> data = field.getFieldData();
    
        // Update field object and icon list in session
        currentSession.setAttribute("data", data);
        currentSession.setAttribute("field", field);
    
        resp.sendRedirect("/index.jsp");
    }
    
    
    
    private Field extractField(HttpSession currentSession) {
        Object fieldAttribute = currentSession.getAttribute("field");
        if (Field.class != fieldAttribute.getClass()) {
            currentSession.invalidate();
            throw new RuntimeException("Session is broken, try one more time");
        }
        return (Field) fieldAttribute;
    }
    Поведението все още не се е променило, но ако стартирате сървъра в режим на отстраняване на грешки и зададете точка на прекъсване на реда, където се изпраща пренасочването, можете да видите „вътрешностите“ на обекта „данни . Там наистина се появява „КРЪС“ под индекса, върху който е щракнато.
  12. Сега е време да покажете кръста в предната част. За целта ще работим с file “index.jsp” и технологията “JSTL” .
    • В секцията <head> добавете:<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
    • В tableта във всеки блок <td> променете индекса на конструкция, която ви позволява да изчислявате стойности. Например, за индекс нула: <td onclick="window.location='/logic?click=0'">${data.get(0).getSign()}</td> Сега, когато щракнете върху клетка, там ще се появи кръст:
  13. Ние направихме своя ход, сега е ред на "нулата". И нека добавим няколко проверки тук, за да не се поставят знаците във вече заети клетки.
    • Трябва да проверите дали клетката, върху която сте щракнали, е празна. В противен случай не правим нищо и изпращаме потребителя на същата page, без да променяме параметрите на сесията.
    • Тъй като броят на клетките на полето е нечетен, е възможно да е поставен кръст, но няма място за нула. Следователно, след като поставим кръст, се опитваме да получим индекса на незаета клетка (метода getEmptyFieldIndex на класа Field). Ако индексът не е отрицателен, тогава поставете нула там. Код:
      package com.tictactoe;
      
      import javax.servlet.RequestDispatcher;
      import javax.servlet.ServletException;
      import javax.servlet.annotation.WebServlet;
      import javax.servlet.http.HttpServlet;
      import javax.servlet.http.HttpServletRequest;
      import javax.servlet.http.HttpServletResponse;
      import javax.servlet.http.HttpSession;
      import java.io.IOException;
      import java.util.List;
      
      @WebServlet(name = "LogicServlet", value = "/logic")
      public class LogicServlet extends HttpServlet {
          @Override
          protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
              // Get the current session
              HttpSession currentSession = req.getSession();
      
              // Get the playfield object from the session
              Field field = extractField(currentSession);
      
              // get the index of the cell that was clicked
              int index = getSelectedIndex(req);
              Sign currentSign = field.getField().get(index);
      
              // Check if the clicked cell is empty.
              // Otherwise, we do nothing and send the user to the same page without changes
              // parameters in the session
              if (Sign.EMPTY != currentSign) {
                  RequestDispatcher dispatcher = getServletContext().getRequestDispatcher("/index.jsp");
                  dispatcher.forward(req, resp);
                  return;
              }
      
              // put a cross in the cell that the user clicked on
              field.getField().put(index, Sign.CROSS);
      
              // Get an empty field cell
              int emptyFieldIndex = field.getEmptyFieldIndex();
      
              if (emptyFieldIndex >= 0) {
                  field.getField().put(emptyFieldIndex, Sign.NOUGHT);
              }
      
              // Read the list of icons
              List<Sign> data = field.getFieldData();
      
              // Update field object and icon list in session
              currentSession.setAttribute("data", data);
              currentSession.setAttribute("field", field);
      
              resp.sendRedirect("/index.jsp");
          }
      
          private int getSelectedIndex(HttpServletRequest request) {
              String click = request.getParameter("click");
              boolean isNumeric = click.chars().allMatch(Character::isDigit);
              return isNumeric ? Integer.parseInt(click) : 0;
          }
      
          private Field extractField(HttpSession currentSession) {
              Object fieldAttribute = currentSession.getAttribute("field");
              if (Field.class != fieldAttribute.getClass()) {
                  currentSession.invalidate();
                  throw new RuntimeException("Session is broken, try one more time");
              }
              return (Field) fieldAttribute;
          }
      }
  14. На този етап можете да поставите кръстчета, AI отговаря с нули. Но няма проверка кога да спрете играта. Това може да стане в три случая:
    • след поредното движение на кръста се образувала линия от три кръста;
    • след следващия обратен ход с нула се образува линия от три нули;
    • след следващото движение на кръста празните клетки свършиха.
    Нека добавим метод, който проверява дали има три кръстчета/нули подред:
    /**
     * The method checks if there are three X/O's in a row.
     * returns true/false
     */
    private boolean checkWin(HttpServletResponse response, HttpSession currentSession, Field field) throws IOException {
        Sign winner = field.checkWin();
        if (Sign.CROSS == winner || Sign.NOUGHT == winner) {
            // Add a flag to indicate that someone has won
            currentSession.setAttribute("winner", winner);
    
            // Read the list of icons
            List<Sign> data = field.getFieldData();
    
            // Update this list in session
            currentSession.setAttribute("data", data);
    
            // helmet redirect
            response.sendRedirect("/index.jsp");
            return true;
        }
        return false;
    }
    Особеността на този метод е, че ако бъде намерен победител, ние добавяме друг параметър към сесията, използвайки който ще променим дисплея в „index.jsp“ в следващите параграфи.
  15. Нека добавим извикване към метода “checkWin ” два пъти към метода “doGet” . Първият път след задаване на кръста, вторият - след задаване на нулата.
    // Check if the cross won after adding the user's last click
    if (checkWin(resp, currentSession, field)) {
        return;
    }
    if (emptyFieldIndex >= 0) {
        field.getField().put(emptyFieldIndex, Sign.NOUGHT);
        // Check if the zero won after adding the last zero
        if (checkWin(resp, currentSession, field)) {
            return;
        }
    }
  16. По отношение на поведението почти нищо не се е променило (с изключение на това, че ако един от знаците спечели, нулите вече не се поставят. Нека използваме параметъра „победител“ в „index.jsp“ и да покажем победителя. Използваме директиви след tableта: c:setc:if
    <hr>
    <c:set var="CROSSES" value="<%=Sign.CROSS%>"/>
    <c:set var="NOUGHTS" value="<%=Sign.NOUGHT%>"/>
    
    <c:if test="${winner == CROSSES}">
        <h1>CROSSES WIN!</h1>
    </c:if>
    <c:if test="${winner == NOUGHTS}">
        <h1>NOUGHTS WIN!</h1>
    </c:if>
    Ако кръстчетата спечелят, съобщението „CROSSES WIN!“ , ако нулите са "NOUGHTS WIN!" . В резултат на това можем да получим един от двата надписа:
  17. Ако има победител, трябва да можете да си отмъстите. За да направите това, имате нужда от бутон, който ще изпрати заявка до сървъра. И сървърът ще направи невалидна текущата сесия и ще пренасочи заявката обратно към “/start” .
    • В “index.jsp” в секцията “head” напишете скрипта “jquery” . Използвайки тази библиотека, ние ще изпратим заявка до сървъра.
      <script src="<c:url value="/static/jquery-3.6.0.min.js"/>"></script>
    • В “index.jsp” в секцията “script” добавете функция, която може да изпраща POST заявка до сървъра. Ще направим функцията синхронна и когато дойде отговор от сървъра, тя ще презареди текущата page.
      <script>
          function restart() {
              $.ajax({
                  url: '/restart',
                  type: 'POST',
                  contentType: 'application/json;charset=UTF-8',
                  async: false,
                  success: function () {
                      location.reload();
                  }
              });
          }
      </script>
    • Вътре в блоковете „c:if“ добавете бутон, който при щракване извиква функцията, която току-що написахме:
      <c:if test="${winner == CROSSES}">
          <h1>CROSSES WIN!</h1>
          <button onclick="restart()">Start again</button>
      </c:if>
      <c:if test="${winner == NOUGHTS}">
          <h1>NOUGHTS WIN!</h1>
          <button onclick="restart()">Start again</button>
      </c:if>
    • Нека създадем нов сървлет, който ще обслужва URL address "/restart" .
      package com.tictactoe;
      
      import javax.servlet.annotation.WebServlet;
      import javax.servlet.http.HttpServlet;
      import javax.servlet.http.HttpServletRequest;
      import javax.servlet.http.HttpServletResponse;
      import java.io.IOException;
      
      @WebServlet(name = "RestartServlet", value = "/restart")
      public class RestartServlet extends HttpServlet {
          @Override
          protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
              req.getSession().invalidate();
              resp.sendRedirect("/start");
          }
      }
      След победата ще се появи бутонът „Започни отново“ . След като щракнете върху него, полето ще бъде напълно изчистено и играта ще започне отначало.
  18. Остава да разгледаме последната ситуация. Ами ако потребителят постави кръстче, нямаше победа и няма място за нула? Тогава това е equalsство и ние ще го обработим сега:
    • В сесията "LogicServlet" добавете друг параметър "draw" , актуализирайте полето "data" и изпратете пренасочване към "index.jsp" :
      // If such a cell exists
      if (emptyFieldIndex >= 0) {}
      // If there is no empty cell and no one wins, then it's a draw
      else {
          // Add a flag to the session that signals that a draw has occurred
          currentSession.setAttribute("draw", true);
      
          // Read the list of icons
          List<Sign> data = field.getFieldData();
      
          // Update this list in session
          currentSession.setAttribute("data", data);
      
          // helmet redirect
          response.sendRedirect("/index.jsp");
          return;
      }
    • В "index.jsp" ще обработим този параметър:
      <c:if test="${draw}">
          <h1>IT'S A DRAW</h1>
          <br>
          <button onclick="restart()">Start again</button>
      </c:if>
      В резултат на теглене ще получим съответното съобщение и предложение да започнем отначало:

Това завършва писането на играта.

Код на класове и файлове, с които са работor

InitServlet

package com.tictactoe;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.List;
import java.util.Map;

@WebServlet(name = "InitServlet", value = "/start")
public class InitServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // Create a new session
        HttpSession currentSession = req.getSession(true);

        // Create a playing field
        Field field = new Field();
        Map<Integer, Sign> fieldData = field.getField();

        // Get a list of field values
        List<Sign> data = field.getFieldData();

        // Adding field parameters to the session (needed to store state between requests)
        currentSession.setAttribute("field", field);
        // and field values ​​sorted by index (required for drawing crosses and zeroes)
        currentSession.setAttribute("data", data);

        // Redirect request to index.jsp page via server
        getServletContext().getRequestDispatcher("/index.jsp").forward(req, resp);
    }
}

LogicServlet

package com.tictactoe;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.List;

@WebServlet(name = "LogicServlet", value = "/logic")
public class LogicServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // Get the current session
        HttpSession currentSession = req.getSession();

        // Get the playfield object from the session
        Field field = extractField(currentSession);

        // get the index of the cell that was clicked
        int index = getSelectedIndex(req);
        Sign currentSign = field.getField().get(index);

        // Check if the clicked cell is empty.
        // Otherwise, we do nothing and send the user to the same page without changes
        // parameters in the session
        if (Sign.EMPTY != currentSign) {
            RequestDispatcher dispatcher = getServletContext().getRequestDispatcher("/index.jsp");
            dispatcher.forward(req, resp);
            return;
        }

        // put a cross in the cell that the user clicked on
        field.getField().put(index, Sign.CROSS);

        // Check if the cross has won after adding the user's last click
        if (checkWin(resp, currentSession, field)) {
            return;
        }

        // Get an empty field cell
        int emptyFieldIndex = field.getEmptyFieldIndex();

        if (emptyFieldIndex >= 0) {
            field.getField().put(emptyFieldIndex, Sign.NOUGHT);
            // Check if the zero won after adding the last zero
            if (checkWin(resp, currentSession, field)) {
                return;
            }
        }
        // If there is no empty cell and no one wins, then it's a draw
        else {
            // Add a flag to the session that signals that a draw has occurred
            currentSession.setAttribute("draw", true);

            // Read the list of icons
            List<Sign> data = field.getFieldData();

            // Update this list in session
            currentSession.setAttribute("data", data);

            // helmet redirect
            resp.sendRedirect("/index.jsp");
            return;
        }

        // Read the list of icons
        List<Sign> data = field.getFieldData();

        // Update field object and icon list in session
        currentSession.setAttribute("data", data);
        currentSession.setAttribute("field", field);

        resp.sendRedirect("/index.jsp");
    }

    /**
     * The method checks if there are three X/O's in a row.
     * returns true/false
     */
    private boolean checkWin(HttpServletResponse response, HttpSession currentSession, Field field) throws IOException {
        Sign winner = field.checkWin();
        if (Sign.CROSS == winner || Sign.NOUGHT == winner) {
            // Add a flag to indicate that someone has won
            currentSession.setAttribute("winner", winner);

            // Read the list of icons
            List<Sign> data = field.getFieldData();

            // Update this list in session
            currentSession.setAttribute("data", data);

            // helmet redirect
            response.sendRedirect("/index.jsp");
            return true;
        }
        return false;
    }

    private int getSelectedIndex(HttpServletRequest request) {
        String click = request.getParameter("click");
        boolean isNumeric = click.chars().allMatch(Character::isDigit);
        return isNumeric ? Integer.parseInt(click) : 0;
    }

    private Field extractField(HttpSession currentSession) {
        Object fieldAttribute = currentSession.getAttribute("field");
        if (Field.class != fieldAttribute.getClass()) {
            currentSession.invalidate();
            throw new RuntimeException("Session is broken, try one more time");
        }
        return (Field) fieldAttribute;
    }
}

Рестартирайте сервлета

package com.tictactoe;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet(name = "RestartServlet", value = "/restart")
public class RestartServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        req.getSession().invalidate();
        resp.sendRedirect("/start");
    }
}

index.jsp _

<%@ page import="com.tictactoe.Sign" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>

<!DOCTYPE html>
<html>
<head>
    <link href="static/main.css" rel="stylesheet">
    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
    <script src="<c:url value="/static/jquery-3.6.0.min.js"/>"></script>
    <title>Tic-Tac-Toe</title>
</head>
<body>
<h1>Tic-Tac-Toe</h1>

<table>
    <tr>
        <td onclick="window.location='/logic?click=0'">${data.get(0).getSign()}</td>
        <td onclick="window.location='/logic?click=1'">${data.get(1).getSign()}</td>
        <td onclick="window.location='/logic?click=2'">${data.get(2).getSign()}</td>
    </tr>
    <tr>
        <td onclick="window.location='/logic?click=3'">${data.get(3).getSign()}</td>
        <td onclick="window.location='/logic?click=4'">${data.get(4).getSign()}</td>
        <td onclick="window.location='/logic?click=5'">${data.get(5).getSign()}</td>
    </tr>
    <tr>
        <td onclick="window.location='/logic?click=6'">${data.get(6).getSign()}</td>
        <td onclick="window.location='/logic?click=7'">${data.get(7).getSign()}</td>
        <td onclick="window.location='/logic?click=8'">${data.get(8).getSign()}</td>
    </tr>
</table>

<hr>
<c:set var="CROSSES" value="<%=Sign.CROSS%>"/>
<c:set var="NOUGHTS" value="<%=Sign.NOUGHT%>"/>

<c:if test="${winner == CROSSES}">
    <h1>CROSSES WIN!</h1>
    <button onclick="restart()">Start again</button>
</c:if>
<c:if test="${winner == NOUGHTS}">
    <h1>NOUGHTS WIN!</h1>
    <button onclick="restart()">Start again</button>
</c:if>
<c:if test="${draw}">
    <h1>IT'S A DRAW</h1>
    <button onclick="restart()">Start again</button>
</c:if>

<script>
    function restart() {
        $.ajax({
            url: '/restart',
            type: 'POST',
            contentType: 'application/json;charset=UTF-8',
            async: false,
            success: function () {
                location.reload();
            }
        });
    }
</script>

</body>
</html>

main.css _

td {
    border: 3px solid black;
    padding: 10px;
    border-collapse: separate;
    margin: 10px;
    width: 100px;
    height: 100px;
    font-size: 50px;
    text-align: center;
    empty-cells: show;
   }