diff --git a/api/admin_test.go b/api/admin_test.go index e6bc556..52fea12 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -174,6 +174,62 @@ func TestAdminResetPreservesLeadersAndClearsFlag(t *testing.T) { } } +func TestAdminResetPreservesConnectedSessionsAndLeaderAuthorization(t *testing.T) { + players := new(Players) + players.Init() + game := new(Game) + sockets := new(Sockets) + sockets.Init(players, game) + admin := new(Admin) + admin.Init(players, sockets, "top-secret", game) + cookie := registerTestAdmin(t, admin, "top-secret") + + leaderID := players.New(TypeAntiPacLeader, "Leader", StatusDisc) + activeID := players.New(TypePacman, "Active", StatusDisc) + leaderConnection := newTestConnection(leaderID) + activeConnection := newTestConnection(activeID) + sockets.hub.registerConnection(leaderConnection) + sockets.hub.registerConnection(activeConnection) + drainTestMessages(leaderConnection) + drainTestMessages(activeConnection) + + request := httptest.NewRequest(http.MethodPost, "/api/admin/reset", nil) + request.AddCookie(cookie) + response := httptest.NewRecorder() + admin.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("reset status = %d, want 204", response.Code) + } + + if len(players.players) != 2 { + t.Errorf("player count after reset = %d, want 2", len(players.players)) + } + if leader := players.Get(leaderID); leader == nil || leader.Type != TypeAntiPacLeader { + t.Errorf("leader after reset = %#v, want AntiPac Leader", leader) + } + if active := players.Get(activeID); active == nil || active.Type != TypeGhost { + t.Errorf("active player after reset = %#v, want Ghost", active) + } + if !sockets.hub.hasConnectionForID(leaderID) || !sockets.hub.hasConnectionForID(activeID) { + t.Error("admin reset replaced a connected player session") + } + if leader, _, authorized := players.LeaderState(leaderID); !authorized || leader.ID != leaderID || leader.Type != TypeAntiPacLeader { + t.Errorf("leader authorization after reset = %#v, authorized %v", leader, authorized) + } + + leaderUpdate := informPlayer(t, receiveTestMessage(t, leaderConnection)) + if leaderUpdate.ID != activeID || leaderUpdate.Type != TypeGhost { + t.Errorf("leader's active-player reset update = %#v, want Ghost for %q", leaderUpdate, activeID) + } + updated := informPlayer(t, receiveTestMessage(t, activeConnection)) + if updated.ID != activeID || updated.Type != TypeGhost { + t.Errorf("active reset update = %#v, want Ghost for %q", updated, activeID) + } + if len(leaderConnection.send) != 0 || len(activeConnection.send) != 0 { + t.Errorf("unexpected extra reset messages: leader=%d active=%d", len(leaderConnection.send), len(activeConnection.send)) + } +} + func TestAdminFlagUpdatesSharedStateAndSocketClients(t *testing.T) { players := new(Players) players.Init() diff --git a/api/etc.go b/api/etc.go index 15c1b9e..bf9f6dc 100644 --- a/api/etc.go +++ b/api/etc.go @@ -21,6 +21,7 @@ const ( CMD_INFORM = "inform" // inform another player change/connection CMD_REMOVE = "remove" // remove a player marker without disclosing a location CMD_STATE = "state" // inform clients of shared game state + CMD_SHUTDOWN = "shutdown" // inform clients of server shutdown // player type TypeHidden PlayerType = 0 diff --git a/api/hub.go b/api/hub.go index b55b4b7..9933d2c 100644 --- a/api/hub.go +++ b/api/hub.go @@ -23,6 +23,12 @@ type Hub struct { inform chan PlayerID state chan GameState clearOffline chan chan struct{} + shutdown chan shutdownEvent +} + +type shutdownEvent struct { + command string + done chan struct{} } func NewHub(players *Players, games ...*Game) *Hub { @@ -38,6 +44,7 @@ func NewHub(players *Players, games ...*Game) *Hub { inform: make(chan PlayerID), state: make(chan GameState), clearOffline: make(chan chan struct{}), + shutdown: make(chan shutdownEvent), } if len(games) > 0 { hub.game = games[0] @@ -61,6 +68,9 @@ func (h *Hub) Run() { case done := <-h.clearOffline: h.clearOfflineLocations() close(done) + case event := <-h.shutdown: + h.broadcastShutDown(event.command) + close(event.done) } } } @@ -178,7 +188,7 @@ func (h *Hub) unregisterConnection(connection *Conn) { if hasCoordinate && isMapVisibleRole(player.Type) { message, ok := informMessage(player, coordinate) if ok { - h.broadcast(message, nil, onlyViewers) + h.broadcastControl(message, nil, onlyViewers) } } else { h.broadcastRemove(connection.playerID, onlyViewers, nil) @@ -293,15 +303,46 @@ func (h *Hub) broadcast(message []byte, origin *Conn, include connectionFilter) } } +func (h *Hub) broadcastControl(message []byte, origin *Conn, include connectionFilter) { + var failedConnections []*Conn + for connection := range h.connections { + if connection == origin || !include(connection) { + continue + } + if !h.enqueueControl(connection, message) { + failedConnections = append(failedConnections, connection) + } + } + for _, connection := range failedConnections { + h.unregisterConnection(connection) + } +} + func (h *Hub) enqueue(connection *Conn, message []byte) bool { select { case connection.send <- message: return true default: + // Channel buffer is full. Client cannot keep up with real-time updates. return false } } +func (h *Hub) enqueueControl(connection *Conn, message []byte) bool { + if h.enqueue(connection, message) { + return true + } + for { + select { + case <-connection.send: + // Drain older queued messages until the buffer is empty to prioritize this critical control message. + default: + // Buffer is now completely drained. Enqueue the critical control message. + return h.enqueue(connection, message) + } + } +} + func informMessage(player PlayerResponse, coordinate Coordinate) ([]byte, bool) { playerJSON, err := json.Marshal(player) if err != nil { @@ -351,7 +392,7 @@ func (h *Hub) broadcastInform(playerID PlayerID, origin *Conn) { if h.connectionCanSee(connection, playerID, player.Type) { outgoing = message } - if !h.enqueue(connection, outgoing) { + if !h.enqueueControl(connection, outgoing) { slowConnections = append(slowConnections, connection) } } @@ -367,7 +408,7 @@ func (h *Hub) broadcastInform(playerID PlayerID, origin *Conn) { if retained && isMapVisibleRole(player.Type) { message, ok := informMessage(player, coordinate) if ok { - h.broadcast(message, nil, onlyViewers) + h.broadcastControl(message, nil, onlyViewers) } return } @@ -411,7 +452,7 @@ func removeMessage(playerID PlayerID) []byte { } func (h *Hub) broadcastRemove(playerID PlayerID, include connectionFilter, origin *Conn) { - h.broadcast(removeMessage(playerID), origin, include) + h.broadcastControl(removeMessage(playerID), origin, include) } func (h *Hub) clearOfflineLocations() { @@ -420,3 +461,15 @@ func (h *Hub) clearOfflineLocations() { h.broadcastRemove(playerID, onlyViewers, nil) } } + +func (h *Hub) broadcastShutDown(command string) { + message, err := json.Marshal(Message{Command: command}) + if err != nil { + return + } + for connection := range h.connections { + if !h.enqueueControl(connection, message) { + h.unregisterConnection(connection) + } + } +} diff --git a/api/socket.go b/api/socket.go index 89c777e..4bfb30a 100644 --- a/api/socket.go +++ b/api/socket.go @@ -36,6 +36,13 @@ type Sockets struct { hub *Hub } +// BroadcastShutDown sends a shutdown command to all connected clients. +func (s *Sockets) BroadcastShutDown(command string) { + done := make(chan struct{}) + s.hub.shutdown <- shutdownEvent{command: command, done: done} + <-done +} + func (s *Sockets) Init(players *Players, games ...*Game) { s.players = players s.hub = NewHub(players, games...) diff --git a/api/socket_test.go b/api/socket_test.go index 0ea15a0..0b80081 100644 --- a/api/socket_test.go +++ b/api/socket_test.go @@ -31,6 +31,112 @@ func TestPlayerStaysConnectedUntilLastSocketDisconnects(t *testing.T) { } } +func TestBroadcastShutdownQueuesShutdownCommand(t *testing.T) { + players := new(Players) + players.Init() + playerID := players.New(TypeGhost, "Player", StatusDisc) + hub := NewHub(players) + connection := newTestConnection(playerID) + hub.registerConnection(connection) + drainTestMessages(connection) + + hub.broadcastShutDown(CMD_SHUTDOWN) + message := receiveTestMessage(t, connection) + if message.Command != CMD_SHUTDOWN { + t.Errorf("shutdown command = %q, want %q", message.Command, CMD_SHUTDOWN) + } +} + +func TestBroadcastShutdownEvictsQueuedMessages(t *testing.T) { + players := new(Players) + players.Init() + playerID := players.New(TypeGhost, "Player", StatusDisc) + hub := NewHub(players) + connection := &Conn{ + playerID: playerID, + role: playerConnection, + send: make(chan []byte, 2), + } + hub.connections[connection] = struct{}{} + connection.send <- []byte("stale move") + connection.send <- []byte("stale state") + + hub.broadcastShutDown(CMD_SHUTDOWN) + + if _, exists := hub.connections[connection]; !exists { + t.Fatal("connection was unregistered after successful prioritized delivery") + } + if len(connection.send) != 1 { + t.Fatalf("queued messages = %d, want 1", len(connection.send)) + } + if message := receiveTestMessage(t, connection); message.Command != CMD_SHUTDOWN { + t.Errorf("shutdown command = %q, want %q", message.Command, CMD_SHUTDOWN) + } +} + +func TestBroadcastShutdownUnregistersUndeliverableConnection(t *testing.T) { + players := new(Players) + players.Init() + playerID := players.New(TypeGhost, "Player", StatusDisc) + hub := NewHub(players) + connection := &Conn{ + playerID: playerID, + role: playerConnection, + send: make(chan []byte), + } + hub.connections[connection] = struct{}{} + + hub.broadcastShutDown(CMD_SHUTDOWN) + + if _, exists := hub.connections[connection]; exists { + t.Error("undeliverable connection remains registered") + } +} + +func TestControlBroadcastEvictsQueuedMessagesForResetUpdates(t *testing.T) { + players := new(Players) + players.Init() + activeID := players.New(TypePacman, "Active", StatusDisc) + offlineID := players.New(TypeGhost, "Offline", StatusDisc) + hub := NewHub(players) + active := &Conn{ + playerID: activeID, + role: playerConnection, + send: make(chan []byte, 1), + } + viewer := &Conn{ + role: viewerConnection, + send: make(chan []byte, 1), + } + hub.connections[active] = struct{}{} + hub.connections[viewer] = struct{}{} + hub.coordinates[activeID] = Coordinate{Latitude: 49.27, Longitude: -122.91} + hub.offlineCoordinates[offlineID] = Coordinate{Latitude: 49.28, Longitude: -122.90} + active.send <- []byte("stale state") + viewer.send <- []byte("stale move") + + hub.clearOfflineLocations() + if _, exists := hub.connections[viewer]; !exists { + t.Fatal("viewer was unregistered after prioritized marker removal") + } + removed := receiveTestMessage(t, viewer) + if removed.Command != CMD_REMOVE || removed.Data != string(offlineID) { + t.Errorf("offline marker removal = %#v", removed) + } + + if _, _, found := players.Update(activeID, TypeGhost); !found { + t.Fatal("reset active player update failed") + } + hub.broadcastInform(activeID, nil) + if _, exists := hub.connections[active]; !exists { + t.Fatal("active connection was unregistered after prioritized reset update") + } + updated := informPlayer(t, receiveTestMessage(t, active)) + if updated.ID != activeID || updated.Type != TypeGhost { + t.Errorf("reset player update = %#v", updated) + } +} + func TestGameStateSnapshotAndBroadcastDoNotChangePlayerConnectionCounts(t *testing.T) { players := new(Players) players.Init() diff --git a/frontend/src/app/core/credentials.service.spec.ts b/frontend/src/app/core/credentials.service.spec.ts index 3b3ddae..18fbdf8 100644 --- a/frontend/src/app/core/credentials.service.spec.ts +++ b/frontend/src/app/core/credentials.service.spec.ts @@ -1,12 +1,146 @@ -import { readCookie } from './credentials.service'; +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; + +import { PAC_WINDOW } from './browser-window.token'; +import { CredentialsService, readCookie } from './credentials.service'; describe('readCookie', () => { it('reads and decodes an exact cookie name', () => { - expect(readCookie('theme=dark; id=AB%20CD; userid=wrong', 'id')).toBe('AB CD'); + expect(readCookie('pacmacro_admin=token; id=AB%20CD; userid=wrong', 'id')).toBe('AB CD'); }); it('returns an empty string for missing or malformed values', () => { expect(readCookie('id=%E0%A4%A', 'id')).toBe(''); - expect(readCookie('theme=dark', 'id')).toBe(''); + expect(readCookie('pacmacro_admin=token', 'id')).toBe(''); + }); +}); + +describe('CredentialsService', () => { + let service: CredentialsService; + let mockDocument: { cookie: string }; + let mockStorage: Record; + let mockWindow: { + location: { protocol: string }; + localStorage: { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + }; + }; + + beforeEach(() => { + mockDocument = { cookie: '' }; + mockStorage = {}; + mockWindow = { + location: { protocol: 'http:' }, + localStorage: { + getItem: (key) => mockStorage[key] ?? null, + setItem: (key, value) => { + mockStorage[key] = value; + }, + }, + }; + + TestBed.configureTestingModule({ + providers: [ + CredentialsService, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: PAC_WINDOW, useValue: mockWindow }, + ], + }); + service = TestBed.inject(CredentialsService); + }); + + it.each(['http:', 'https:'] as const)('expires the id cookie over %s', (protocol) => { + mockWindow.location.protocol = protocol; + mockDocument.cookie = 'id=ABC; pacmacro_admin=token'; + + service.clear(); + + const secure = protocol === 'https:' ? '; Secure' : ''; + expect(mockDocument.cookie).toBe(`id=; Path=/; SameSite=Lax${secure}; Max-Age=0`); + }); + + it('leaves the cookie untouched when PAC_WINDOW is null', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CredentialsService, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: PAC_WINDOW, useValue: null }, + ], + }); + service = TestBed.inject(CredentialsService); + mockDocument.cookie = 'id=ABC'; + + service.clear(); + + expect(mockDocument.cookie).toBe('id=ABC'); + }); + + it('saves and retrieves the player name for auto re-registration', () => { + service.savePlayerName('Odin'); + + expect(service.getPlayerName()).toBe('Odin'); + }); + + it('returns an empty player name when localStorage throws on get', () => { + TestBed.resetTestingModule(); + const throwingWindow = { + location: { protocol: 'http:' }, + localStorage: { + getItem: () => { + throw new Error('unavailable'); + }, + setItem: () => undefined, + }, + }; + TestBed.configureTestingModule({ + providers: [ + CredentialsService, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: PAC_WINDOW, useValue: throwingWindow }, + ], + }); + service = TestBed.inject(CredentialsService); + + expect(service.getPlayerName()).toBe(''); + }); + + it('silently ignores localStorage errors when saving the player name', () => { + TestBed.resetTestingModule(); + const throwingWindow = { + location: { protocol: 'http:' }, + localStorage: { + getItem: () => null, + setItem: () => { + throw new Error('quota exceeded'); + }, + }, + }; + TestBed.configureTestingModule({ + providers: [ + CredentialsService, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: PAC_WINDOW, useValue: throwingWindow }, + ], + }); + service = TestBed.inject(CredentialsService); + + expect(() => service.savePlayerName('Odin')).not.toThrow(); + }); + + it('returns an empty player name and ignores saves when PAC_WINDOW is null', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CredentialsService, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: PAC_WINDOW, useValue: null }, + ], + }); + service = TestBed.inject(CredentialsService); + + expect(service.getPlayerName()).toBe(''); + expect(() => service.savePlayerName('Odin')).not.toThrow(); }); }); diff --git a/frontend/src/app/core/credentials.service.ts b/frontend/src/app/core/credentials.service.ts index 1c0b881..e7fba9f 100644 --- a/frontend/src/app/core/credentials.service.ts +++ b/frontend/src/app/core/credentials.service.ts @@ -4,6 +4,8 @@ import { inject, Service } from '@angular/core'; import { PAC_WINDOW } from './browser-window.token'; import { Credentials } from './game.models'; +const PLAYER_NAME_KEY = 'playerName'; + export function readCookie(cookieHeader: string, name: string): string { const prefix = `${name}=`; const value = cookieHeader @@ -47,4 +49,29 @@ export class CredentialsService { const attributes = `; Path=/; SameSite=Lax${secure}`; this.document.cookie = `id=${encodeURIComponent(credentials.id)}${attributes}`; } + + getPlayerName(): string { + try { + return this.browserWindow?.localStorage.getItem(PLAYER_NAME_KEY) ?? ''; + } catch { + return ''; + } + } + + savePlayerName(name: string): void { + try { + this.browserWindow?.localStorage.setItem(PLAYER_NAME_KEY, name); + } catch { + // Local storage may be unavailable or full; re-registration can fall back to the form. + } + } + + clear(): void { + if (!this.browserWindow) { + return; + } + + const secure = this.browserWindow.location.protocol === 'https:' ? '; Secure' : ''; + this.document.cookie = `id=; Path=/; SameSite=Lax${secure}; Max-Age=0`; + } } diff --git a/frontend/src/app/core/game.models.ts b/frontend/src/app/core/game.models.ts index 54a801c..e358e26 100644 --- a/frontend/src/app/core/game.models.ts +++ b/frontend/src/app/core/game.models.ts @@ -52,7 +52,7 @@ export interface LivePlayer { export interface SocketMessage { coordinate?: Coordinate; - command: 'inform' | 'move' | 'remove' | 'state' | string; + command: 'inform' | 'move' | 'remove' | 'state' | 'shutdown' | string; data: string; } diff --git a/frontend/src/app/core/sockets/game-socket.service.spec.ts b/frontend/src/app/core/sockets/game-socket.service.spec.ts index e0b6040..1efa70e 100644 --- a/frontend/src/app/core/sockets/game-socket.service.spec.ts +++ b/frontend/src/app/core/sockets/game-socket.service.spec.ts @@ -93,6 +93,10 @@ describe('GameSocketService', () => { } }); + function advanceToNextReconnect(): void { + vi.advanceTimersToNextTimer(); + } + it('uses the player URL, invokes the callback, and sends coordinates as JSON', () => { const onConnected = vi.fn(); service.start('A B/C', onConnected); @@ -203,6 +207,44 @@ describe('GameSocketService', () => { expect(service.status()).toContain('invalid game update'); }); + it('ends a player session when the server sends shutdown', () => { + vi.useFakeTimers(); + MockGameWebSocket.closeSynchronously = false; + const onSessionExpired = vi.fn(); + const onServerShutdown = vi.fn(); + service.start('ABCD', () => undefined, onSessionExpired, onServerShutdown); + const first = MockGameWebSocket.instances[0]; + first.open(); + const lateClose = first.onclose; + + first.message({ command: 'shutdown', data: '' }); + lateClose?.(new CloseEvent('close', { code: 1006, wasClean: false })); + service.resume(); + vi.runAllTimers(); + + expect(service.state()).toBe('shutdown'); + expect(service.status()).toBe('The server stopped. Register to join the next game.'); + expect(MockGameWebSocket.instances).toHaveLength(1); + expect(onServerShutdown).toHaveBeenCalledOnce(); + expect(onSessionExpired).not.toHaveBeenCalled(); + expect(service.sendCoordinate({ latitude: 49.2, longitude: -123 })).toBe(false); + }); + + it('ends a viewer session when the server sends shutdown', () => { + vi.useFakeTimers(); + service.startViewer(); + const first = MockGameWebSocket.instances[0]; + first.open(); + + first.message({ command: 'shutdown', data: '' }); + service.resume(); + vi.runAllTimers(); + + expect(service.state()).toBe('shutdown'); + expect(service.status()).toBe('The server stopped the admin map connection.'); + expect(MockGameWebSocket.instances).toHaveLength(1); + }); + it.each([ ['clean', true], ['abnormal', false], @@ -227,7 +269,7 @@ describe('GameSocketService', () => { first.serverClose(wasClean); expect(service.status()).toContain('Admin map connection lost'); - vi.advanceTimersByTime(1000); + advanceToNextReconnect(); expect(MockGameWebSocket.instances).toHaveLength(2); const second = MockGameWebSocket.instances[1]; @@ -297,4 +339,102 @@ describe('GameSocketService', () => { expect(service.state()).toBe('error'); expect(service.status()).toContain('Register as admin again in this browser'); }); + + it('expires the session after three consecutive failed player connections', () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const onSessionExpired = vi.fn(); + service.start('ABCD', () => undefined, onSessionExpired); + + MockGameWebSocket.instances[0].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[1].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[2].serverClose(false); + + expect(service.sessionExpired()).toBe(true); + expect(service.state()).toBe('error'); + expect(service.status()).toContain('Session has expired as game server restarted.'); + expect(MockGameWebSocket.instances).toHaveLength(3); + expect(onSessionExpired).toHaveBeenCalledOnce(); + }); + + it('reconnects without expiring the session after fewer than three failures', () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const onSessionExpired = vi.fn(); + service.start('ABCD', () => undefined, onSessionExpired); + + MockGameWebSocket.instances[0].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[1].serverClose(false); + advanceToNextReconnect(); + + expect(service.sessionExpired()).toBe(false); + expect(onSessionExpired).not.toHaveBeenCalled(); + expect(service.state()).toBe('connecting'); + expect(MockGameWebSocket.instances).toHaveLength(3); + }); + + it('resets the failure counter when a player connection succeeds', () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + service.start('ABCD', () => undefined); + + MockGameWebSocket.instances[0].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[1].serverClose(false); + advanceToNextReconnect(); + + MockGameWebSocket.instances[2].open(); + MockGameWebSocket.instances[2].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[3].serverClose(false); + advanceToNextReconnect(); + + expect(service.sessionExpired()).toBe(false); + expect(MockGameWebSocket.instances).toHaveLength(5); + }); + + it('never expires the session for a viewer socket', () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + service.startViewer(); + + MockGameWebSocket.instances[0].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[1].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[2].serverClose(false); + advanceToNextReconnect(); + + expect(service.sessionExpired()).toBe(false); + expect(MockGameWebSocket.instances).toHaveLength(4); + }); + + it('start and stop clear an expired session', () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + service.start('ABCD', () => undefined); + + MockGameWebSocket.instances[0].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[1].serverClose(false); + advanceToNextReconnect(); + MockGameWebSocket.instances[2].serverClose(false); + + expect(service.sessionExpired()).toBe(true); + + service.stop(); + expect(service.sessionExpired()).toBe(false); + + service.start('ABCD', () => undefined); + expect(service.sessionExpired()).toBe(false); + expect(MockGameWebSocket.instances).toHaveLength(4); + }); }); diff --git a/frontend/src/app/core/sockets/game-socket.service.ts b/frontend/src/app/core/sockets/game-socket.service.ts index 15a74f8..07dec17 100644 --- a/frontend/src/app/core/sockets/game-socket.service.ts +++ b/frontend/src/app/core/sockets/game-socket.service.ts @@ -29,18 +29,32 @@ export class GameSocketService extends WebSocketService { private playerId: string | null = null; private mode: SocketMode | null = null; private onConnected: (() => void) | null = null; + private onSessionExpired: (() => void) | null = null; + private onServerShutdown: (() => void) | null = null; private reconnecting = false; private suspendedReason = 'Paused while the browser is offline.'; + private consecutiveFailures = 0; private readonly statusMessage = signal(null); readonly players = signal>({}); readonly isFlagFound = signal(false); - - start(id: string, onConnected: () => void): void { + readonly sessionExpired = signal(false); + readonly MAX_FAILED_ATTEMPTS = 3; + + start( + id: string, + onConnected: () => void, + onSessionExpired: () => void = () => undefined, + onServerShutdown: () => void = () => undefined, + ): void { this.stop(); this.mode = 'player'; this.playerId = id; this.onConnected = onConnected; + this.onSessionExpired = onSessionExpired; + this.onServerShutdown = onServerShutdown; + this.consecutiveFailures = 0; + this.sessionExpired.set(false); this.resume(); } @@ -72,8 +86,12 @@ export class GameSocketService extends WebSocketService { this.mode = null; this.playerId = null; this.onConnected = null; + this.onSessionExpired = null; + this.onServerShutdown = null; this.reconnecting = false; this.statusMessage.set(null); + this.consecutiveFailures = 0; + this.sessionExpired.set(false); this.disconnect(); } @@ -81,6 +99,34 @@ export class GameSocketService extends WebSocketService { return this.mode === 'player' && isCoordinate(coordinate) && this.sendMessage(coordinate); } + private endForServerShutdown(): void { + const mode = this.mode; + const onServerShutdown = this.onServerShutdown; + + this.mode = null; + this.playerId = null; + this.onConnected = null; + this.onSessionExpired = null; + this.onServerShutdown = null; + this.reconnecting = false; + this.consecutiveFailures = 0; + this.sessionExpired.set(false); + + // Incrementing the connection identity before unsubscribing makes any + // queued retry or late close event belong to an obsolete connection. + this.disconnect(); + this.state.set('shutdown'); + this.statusMessage.set( + mode === 'player' + ? 'The server stopped. Register to join the next game.' + : 'The server stopped the admin map connection.', + ); + + if (mode === 'player') { + onServerShutdown?.(); + } + } + setInitialState(state: GameState): void { this.isFlagFound.set(state.isFlagFound); } @@ -96,6 +142,7 @@ export class GameSocketService extends WebSocketService { this.players.set({}); this.reconnecting = false; this.statusMessage.set(null); + this.consecutiveFailures = 0; this.onConnected?.(); } @@ -103,6 +150,24 @@ export class GameSocketService extends WebSocketService { this.reconnecting = true; } + protected override shouldReconnect(closeEvent: CloseEvent): boolean { + if (this.mode !== 'player') { + return true; + } + + this.consecutiveFailures++; + + if (this.consecutiveFailures >= this.MAX_FAILED_ATTEMPTS) { + this.sessionExpired.set(true); + this.statusMessage.set( + 'Session has expired as game server restarted.', + ); + this.onSessionExpired?.(); + return false; + } + return true; + } + protected override onSocketError(): void { this.statusMessage.set( this.mode === 'viewer' @@ -170,6 +235,11 @@ export class GameSocketService extends WebSocketService { return; } + if (message.command === 'shutdown') { + this.endForServerShutdown(); + return; + } + if (message.command === 'remove') { this.players.update((players) => { if (!(message.data in players)) { @@ -232,6 +302,7 @@ function isSocketMessage(value: unknown): value is SocketMessage { switch (value['command']) { case 'remove': + case 'shutdown': return true; case 'inform': case 'move': diff --git a/frontend/src/app/core/sockets/leader-socket.service.spec.ts b/frontend/src/app/core/sockets/leader-socket.service.spec.ts index c5d1954..5d399bb 100644 --- a/frontend/src/app/core/sockets/leader-socket.service.spec.ts +++ b/frontend/src/app/core/sockets/leader-socket.service.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { PAC_WINDOW } from '../browser-window.token'; import { LeaderState, PlayerStatus, PlayerType } from '../game.models'; import { LeaderSocketService } from './leader-socket.service'; +import { WebSocketService } from './websocket.service'; class MockLeaderWebSocket { static readonly CONNECTING = 0; @@ -67,6 +68,11 @@ const player = { status: PlayerStatus.Connected, }; +function reconnectDelay(attempt: number): number { + const delays = WebSocketService.RECONNECT_DELAYS; + return delays[Math.min(attempt - 1, delays.length - 1)]; +} + describe('LeaderSocketService', () => { let service: LeaderSocketService; let originalWebSocket: typeof WebSocket; @@ -204,7 +210,7 @@ describe('LeaderSocketService', () => { first.serverClose(wasClean); expect(service.status()).toContain('Leader feed lost'); - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(reconnectDelay(1)); expect(MockLeaderWebSocket.instances).toHaveLength(2); MockLeaderWebSocket.instances[1].open(); @@ -257,7 +263,7 @@ describe('LeaderSocketService', () => { expect(service.players()).toEqual([]); expect(service.isFlagFound()).toBe(false); expect(service.status()).toContain('Waiting for the role to be restored'); - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(reconnectDelay(1)); const restoredSocket = MockLeaderWebSocket.instances[1]; restoredSocket.open(); @@ -286,7 +292,7 @@ describe('LeaderSocketService', () => { socket.serverClose(true, 1008, 'Leader authentication required'); expect(service.state()).toBe('revoked'); expect(service.status()).toContain('Leader access was revoked'); - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(reconnectDelay(1)); expect(MockLeaderWebSocket.instances).toHaveLength(2); MockLeaderWebSocket.instances[1].open(); diff --git a/frontend/src/app/core/sockets/websocket.service.spec.ts b/frontend/src/app/core/sockets/websocket.service.spec.ts index 37cc803..54fbeb8 100644 --- a/frontend/src/app/core/sockets/websocket.service.spec.ts +++ b/frontend/src/app/core/sockets/websocket.service.spec.ts @@ -99,6 +99,11 @@ class MockRxWebSocket { } } +function reconnectDelay(attempt: number): number { + const delays = WebSocketService.RECONNECT_DELAYS; + return delays[Math.min(attempt - 1, delays.length - 1)]; +} + describe('WebSocketService', () => { let service: TestWebSocketService; let originalWebSocket: typeof WebSocket; @@ -200,7 +205,7 @@ describe('WebSocketService', () => { first.serverClose(wasClean); expect(service.transportState()).toBe('connecting'); - vi.advanceTimersByTime(999); + vi.advanceTimersByTime(reconnectDelay(1) - 1); expect(MockRxWebSocket.instances).toHaveLength(1); vi.advanceTimersByTime(1); @@ -225,7 +230,7 @@ describe('WebSocketService', () => { Object.defineProperty(window.navigator, 'onLine', { configurable: true, value: true }); window.dispatchEvent(new Event('online')); - vi.advanceTimersByTime(999); + vi.advanceTimersByTime(reconnectDelay(1) - 1); expect(MockRxWebSocket.instances).toHaveLength(1); vi.advanceTimersByTime(1); expect(MockRxWebSocket.instances).toHaveLength(2); @@ -257,7 +262,7 @@ describe('WebSocketService', () => { expect(service.requestReconnect('revoked')).toBe(true); expect(service.transportState()).toBe('revoked'); - vi.advanceTimersByTime(999); + vi.advanceTimersByTime(reconnectDelay(1) - 1); expect(MockRxWebSocket.instances).toHaveLength(1); vi.advanceTimersByTime(1); diff --git a/frontend/src/app/core/sockets/websocket.service.ts b/frontend/src/app/core/sockets/websocket.service.ts index 8c090c8..94062da 100644 --- a/frontend/src/app/core/sockets/websocket.service.ts +++ b/frontend/src/app/core/sockets/websocket.service.ts @@ -11,6 +11,7 @@ export type TransportState = | 'offline' // No network activity detected | 'suspended' // The client has deliberately paused transport | 'revoked' // For leaders that have been demoted to non-leaders + | 'shutdown' // The server deliberately ended the session | 'error'; // An error has occurred, the client may be attempting to reconnect @Service({ autoProvided: false }) @@ -281,6 +282,9 @@ export abstract class WebSocketService { case 'revoked': { return 'Websocket access revoked.'; } + case 'shutdown': { + return 'The server has stopped this connection.'; + } case 'error': { return 'Error with the websocket.'; } diff --git a/frontend/src/app/pages/game-page/game-page.component.spec.ts b/frontend/src/app/pages/game-page/game-page.component.spec.ts index 5d040de..50ed35e 100644 --- a/frontend/src/app/pages/game-page/game-page.component.spec.ts +++ b/frontend/src/app/pages/game-page/game-page.component.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { ApiService } from '../../core/api.service'; import { CredentialsService } from '../../core/credentials.service'; @@ -11,50 +11,98 @@ import { MapInfo, PlayerStatus, PlayerType } from '../../core/game.models'; import { WakeLockService } from '../../core/wake-lock.service'; import { GamePageComponent } from './game-page.component'; +const map: MapInfo = { + min: { latitude: 49.27, longitude: -122.92 }, + max: { latitude: 49.28, longitude: -122.9 }, + width: 32, + height: 32, + isFlagFound: false, +}; +const api = { + getMap: vi.fn(() => of(map)), + registerPlayer: vi.fn(() => of({ id: 'NEWID' })), +}; +const credentials = { + get: vi.fn(() => ({ id: 'SELF' })), + save: vi.fn(), + getPlayerName: vi.fn(() => ''), + savePlayerName: vi.fn(), + clear: vi.fn(), +}; +const router = { navigateByUrl: vi.fn() }; +let triggerSessionExpired: (() => void) | null = null; +let triggerServerShutdown: (() => void) | null = null; +const gameSocket = { + players: signal({ + SELF: { + coordinate: { latitude: 49.275, longitude: -122.91 }, + player: { + id: 'SELF', + name: 'Leader', + type: PlayerType.Ghost, + status: PlayerStatus.Connected, + }, + }, + }), + status: signal('Connected.'), + isFlagFound: signal(false), + sessionExpired: signal(false), + start: vi.fn( + ( + _id: string, + _onConnected: () => void, + onSessionExpired: () => void, + onServerShutdown: () => void, + ) => { + triggerSessionExpired = onSessionExpired; + triggerServerShutdown = onServerShutdown; + gameSocket.sessionExpired.set(false); + }, + ), + stop: vi.fn(() => gameSocket.sessionExpired.set(false)), + resume: vi.fn(), + suspend: vi.fn(), + sendCoordinate: vi.fn(), + setInitialState: vi.fn(), +}; +const geolocation = { + status: signal('Ready.'), + start: vi.fn(), + stop: vi.fn(), +}; +const wakeLock = { + supported: signal(true), + enabled: signal(false), + status: signal('Screen wake lock is off.'), + initialize: vi.fn(), + setEnabled: vi.fn(async () => undefined), + handleVisibilityChange: vi.fn(async () => undefined), + release: vi.fn(async () => undefined), +}; + +async function configureTestBed(): Promise { + await TestBed.configureTestingModule({ + imports: [GamePageComponent], + providers: [ + { provide: ApiService, useValue: api }, + { provide: CredentialsService, useValue: credentials }, + { provide: Router, useValue: router }, + ], + }) + .overrideComponent(GamePageComponent, { + set: { + providers: [ + { provide: GameSocketService, useValue: gameSocket }, + { provide: GeolocationService, useValue: geolocation }, + { provide: WakeLockService, useValue: wakeLock }, + ], + }, + }) + .compileComponents(); +} + describe('GamePageComponent leader link', () => { let fixture: ComponentFixture; - const map: MapInfo = { - min: { latitude: 49.27, longitude: -122.92 }, - max: { latitude: 49.28, longitude: -122.9 }, - width: 32, - height: 32, - isFlagFound: false, - }; - const gameSocket = { - players: signal({ - SELF: { - coordinate: { latitude: 49.275, longitude: -122.91 }, - player: { - id: 'SELF', - name: 'Leader', - type: PlayerType.Ghost, - status: PlayerStatus.Connected, - }, - }, - }), - status: signal('Connected.'), - isFlagFound: signal(false), - start: vi.fn(), - stop: vi.fn(), - resume: vi.fn(), - suspend: vi.fn(), - sendCoordinate: vi.fn(), - setInitialState: vi.fn(), - }; - const geolocation = { - status: signal('Ready.'), - start: vi.fn(), - stop: vi.fn(), - }; - const wakeLock = { - supported: signal(true), - enabled: signal(false), - status: signal('Screen wake lock is off.'), - initialize: vi.fn(), - setEnabled: vi.fn(async () => undefined), - handleVisibilityChange: vi.fn(async () => undefined), - release: vi.fn(async () => undefined), - }; beforeEach(async () => { gameSocket.players.update((players) => ({ @@ -65,24 +113,7 @@ describe('GamePageComponent leader link', () => { }, })); - await TestBed.configureTestingModule({ - imports: [GamePageComponent], - providers: [ - { provide: ApiService, useValue: { getMap: vi.fn(() => of(map)) } }, - { provide: CredentialsService, useValue: { get: () => ({ id: 'SELF' }) } }, - { provide: Router, useValue: { navigateByUrl: vi.fn() } }, - ], - }) - .overrideComponent(GamePageComponent, { - set: { - providers: [ - { provide: GameSocketService, useValue: gameSocket }, - { provide: GeolocationService, useValue: geolocation }, - { provide: WakeLockService, useValue: wakeLock }, - ], - }, - }) - .compileComponents(); + await configureTestBed(); }); async function render(playerType: PlayerType): Promise { @@ -110,6 +141,116 @@ describe('GamePageComponent leader link', () => { it('does not show the leader link to a non-leader', async () => { const page = await render(PlayerType.Ghost); - expect(page.querySelector('.game-page__leader-link')).toBeNull(); + const link = page.querySelector('.game-page__leader-link a'); + expect(link?.style.visibility).toBe('hidden'); + }); +}); + +describe('GamePageComponent re-registration', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + vi.clearAllMocks(); + gameSocket.sessionExpired.set(false); + triggerSessionExpired = null; + triggerServerShutdown = null; + await configureTestBed(); + }); + + async function render(): Promise { + fixture = TestBed.createComponent(GamePageComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + it('clears credentials and redirects to /register when no name is saved', async () => { + credentials.getPlayerName.mockReturnValue(''); + await render(); + + triggerSessionExpired?.(); + await vi.waitFor(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/register'); + }); + + expect(credentials.clear).toHaveBeenCalled(); + expect(gameSocket.stop).toHaveBeenCalled(); + }); + + it('re-registers with the saved name and reconnects', async () => { + credentials.getPlayerName.mockReturnValue('Odin'); + api.registerPlayer.mockReturnValue(of({ id: 'NEWID' })); + const page = await render(); + + triggerSessionExpired?.(); + await vi.waitFor(() => { + expect(gameSocket.start).toHaveBeenLastCalledWith( + 'NEWID', + expect.any(Function), + expect.any(Function), + expect.any(Function), + ); + }); + fixture.detectChanges(); + + expect(api.registerPlayer).toHaveBeenCalledWith('Odin'); + expect(credentials.save).toHaveBeenCalledWith({ id: 'NEWID' }); + expect(credentials.clear).not.toHaveBeenCalled(); + expect(page.textContent).toContain('Re-registered. Reconnecting…'); + + const startCalls = gameSocket.start.mock.calls; + const onConnected = startCalls[startCalls.length - 1][1] as () => void; + expect(startCalls[startCalls.length - 1][0]).toBe('NEWID'); + onConnected(); + fixture.detectChanges(); + + expect(geolocation.start).toHaveBeenCalledWith(expect.any(Function)); + expect(page.textContent).toContain('Connected to PacMacro.'); + }); + + it('clears the player session and redirects when the server shuts down', async () => { + await render(); + + triggerServerShutdown?.(); + + expect(geolocation.stop).toHaveBeenCalled(); + expect(credentials.clear).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/register', { + state: { serverStopped: true }, + }); + expect(api.registerPlayer).not.toHaveBeenCalled(); + }); + + it('clears credentials and redirects when re-registration fails', async () => { + credentials.getPlayerName.mockReturnValue('Odin'); + api.registerPlayer.mockReturnValue(throwError(() => new Error('API is down'))); + const page = await render(); + + triggerSessionExpired?.(); + await vi.waitFor(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/register'); + }); + fixture.detectChanges(); + + expect(credentials.clear).toHaveBeenCalled(); + expect(gameSocket.stop).toHaveBeenCalled(); + expect(page.textContent).toContain('Could not re-register. Redirecting…'); + }); + + it('treats an empty player ID from the API as a failure', async () => { + credentials.getPlayerName.mockReturnValue('Odin'); + api.registerPlayer.mockReturnValue(of({ id: ' ' })); + const page = await render(); + + triggerSessionExpired?.(); + await vi.waitFor(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/register'); + }); + fixture.detectChanges(); + + expect(credentials.clear).toHaveBeenCalled(); + expect(gameSocket.stop).toHaveBeenCalled(); + expect(page.textContent).toContain('Could not re-register. Redirecting…'); }); }); diff --git a/frontend/src/app/pages/game-page/game-page.component.ts b/frontend/src/app/pages/game-page/game-page.component.ts index b435cb3..1f2c9e1 100644 --- a/frontend/src/app/pages/game-page/game-page.component.ts +++ b/frontend/src/app/pages/game-page/game-page.component.ts @@ -41,6 +41,7 @@ export class GamePageComponent { protected readonly map = signal(null); protected readonly selfId = signal(''); protected readonly pageStatus = signal('Loading the game map…'); + protected readonly selfSummary = computed(() => { const player = this.socket.players()[this.selfId()]?.player; return player ? `${player.name} (${player.id}) is ${typeLabel(player.type)}` : ''; @@ -58,6 +59,11 @@ export class GamePageComponent { this.geolocation.stop(); this.socket.suspend('Offline. Waiting for a network connection…'); }; + private readonly onServerShutdown = () => { + this.geolocation.stop(); + this.credentials.clear(); + void this.router.navigateByUrl('/register', { state: { serverStopped: true } }); + }; constructor() { afterNextRender(() => void this.initialize()); @@ -68,6 +74,36 @@ export class GamePageComponent { await this.wakeLock.setEnabled((event.target as HTMLInputElement).checked); } + private async autoReregister(): Promise { + const name = this.credentials.getPlayerName(); + if (!name) { + this.credentials.clear(); + this.socket.stop(); + await this.router.navigateByUrl('/register'); + return; + } + + this.pageStatus.set('Re-registering…'); + + try { + const response = await firstValueFrom(this.api.registerPlayer(name)); + const id = response.id.trim(); + if (!id) { + throw new Error('The API returned an empty player ID.'); + } + + this.credentials.save({ id }); + this.selfId.set(id); + this.pageStatus.set('Re-registered. Reconnecting…'); + this.connectAs(id); + } catch { + this.credentials.clear(); + this.socket.stop(); + this.pageStatus.set('Could not re-register. Redirecting…'); + await this.router.navigateByUrl('/register'); + } + } + private async initialize(): Promise { if (!this.browserWindow) { return; @@ -96,10 +132,19 @@ export class GamePageComponent { this.browserWindow.document.addEventListener('visibilitychange', this.onVisibilityChange); this.browserWindow.addEventListener('online', this.onOnline); this.browserWindow.addEventListener('offline', this.onOffline); - this.socket.start(credentials.id, () => { - this.pageStatus.set('Connected to PacMacro.'); - this.geolocation.start((coordinate) => this.socket.sendCoordinate(coordinate)); - }); + this.connectAs(credentials.id); + } + + private connectAs(id: string): void { + this.socket.start( + id, + () => { + this.pageStatus.set('Connected to PacMacro.'); + this.geolocation.start((coordinate) => this.socket.sendCoordinate(coordinate)); + }, + () => void this.autoReregister(), + this.onServerShutdown, + ); } private cleanup(): void { diff --git a/frontend/src/app/pages/register-page/register-page.component.spec.ts b/frontend/src/app/pages/register-page/register-page.component.spec.ts index e560a89..8c4b0df 100644 --- a/frontend/src/app/pages/register-page/register-page.component.spec.ts +++ b/frontend/src/app/pages/register-page/register-page.component.spec.ts @@ -1,3 +1,4 @@ +import { Location } from '@angular/common'; import { WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -12,16 +13,19 @@ describe('RegisterPageComponent', () => { registerAdmin: vi.fn(() => of(void 0)), registerPlayer: vi.fn(() => of({ id: 'ABCD' })), }; - const credentials = { save: vi.fn() }; + const credentials = { save: vi.fn(), savePlayerName: vi.fn(), getPlayerName: vi.fn(() => '') }; + const location = { getState: vi.fn(() => ({})) }; const router = { navigateByUrl: vi.fn(() => Promise.resolve(true)) }; beforeEach(() => { vi.clearAllMocks(); + location.getState.mockReturnValue({}); TestBed.configureTestingModule({ imports: [RegisterPageComponent], providers: [ { provide: ApiService, useValue: api }, { provide: CredentialsService, useValue: credentials }, + { provide: Location, useValue: location }, { provide: Router, useValue: router }, ], }); @@ -39,6 +43,7 @@ describe('RegisterPageComponent', () => { expect(api.registerPlayer).toHaveBeenCalledWith('Test2'); expect(api.registerAdmin).not.toHaveBeenCalled(); expect(credentials.save).toHaveBeenCalledWith({ id: 'ABCD' }); + expect(credentials.savePlayerName).toHaveBeenCalledWith('Test2'); expect(router.navigateByUrl).toHaveBeenCalledWith('/'); }); @@ -52,6 +57,24 @@ describe('RegisterPageComponent', () => { await component.submit(submitEvent()); expect(api.registerPlayer).not.toHaveBeenCalled(); + expect(credentials.save).not.toHaveBeenCalled(); + expect(credentials.savePlayerName).not.toHaveBeenCalled(); + }); + + it('pre-fills the name from the saved player name', () => { + credentials.getPlayerName.mockReturnValue('SavedPlayer'); + const component = TestBed.createComponent(RegisterPageComponent) + .componentInstance as unknown as RegisterPageHarness; + + expect(component.registrationModel().name).toBe('SavedPlayer'); + }); + + it('shows the shutdown message when routed from a stopped server', () => { + location.getState.mockReturnValue({ serverStopped: true }); + const component = TestBed.createComponent(RegisterPageComponent) + .componentInstance as unknown as RegisterPageHarness; + + expect(component.status()).toBe('The server stopped. Register to join the next game.'); }); }); @@ -59,6 +82,7 @@ interface RegisterPageHarness { registrationModel: WritableSignal<{ name: string; }>; + status: WritableSignal; submit(event: SubmitEvent): Promise; } diff --git a/frontend/src/app/pages/register-page/register-page.component.ts b/frontend/src/app/pages/register-page/register-page.component.ts index c04ca41..180b61f 100644 --- a/frontend/src/app/pages/register-page/register-page.component.ts +++ b/frontend/src/app/pages/register-page/register-page.component.ts @@ -1,3 +1,4 @@ +import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { form, @@ -27,10 +28,11 @@ interface RegistrationModel { export class RegisterPageComponent { private readonly api = inject(ApiService); private readonly credentials = inject(CredentialsService); + private readonly location = inject(Location); private readonly router = inject(Router); protected readonly registrationModel = signal({ - name: '', + name: this.credentials.getPlayerName() ?? '', }); protected readonly registrationForm = form(this.registrationModel, (registration) => { @@ -38,7 +40,11 @@ export class RegisterPageComponent { maxLength(registration.name, 80, { message: 'Your name must be 80 characters or fewer.' }); }); - protected readonly status = signal(''); + protected readonly status = signal( + (this.location.getState() as { serverStopped?: boolean })?.serverStopped + ? 'The server stopped. Register to join the next game.' + : '', + ); protected async submit(event: SubmitEvent): Promise { event.preventDefault(); @@ -62,6 +68,7 @@ export class RegisterPageComponent { throw new Error('The API returned an empty player ID.'); } this.credentials.save({ id }); + this.credentials.savePlayerName(trimmedName); await this.router.navigateByUrl('/'); } catch (error) { this.status.set('Registration failed. Check your details and the API connection.'); diff --git a/main.go b/main.go index 5531ddb..3905dbb 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,9 @@ import ( "log" "net/http" "os" + "os/signal" + "syscall" + "time" "github.com/joho/godotenv" "pacmacro/api" @@ -81,5 +84,21 @@ func main() { // PacMacro API is served on port 49152. // this should be proxied inside the web server used. - log.Fatal(http.ListenAndServe(port, nil)) + go func() { + if err := http.ListenAndServe(port, nil); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + // Block until SIGINT (Ctrl+C) or SIGTERM (systemd stop/restart). + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + fmt.Println("Shutdown signal received. Notifying players...") + sock.BroadcastShutDown(api.CMD_SHUTDOWN) + + // Give write pumps ~1 second to flush the shutdown message before exiting. + time.Sleep(1 * time.Second) + fmt.Println("Server exiting.") }