Skip to content
1 change: 1 addition & 0 deletions api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func (a *Admin) ServeReset(w http.ResponseWriter, r *http.Request) {
if !a.authorizePost(w, r) {
return
}
a.sockets.BroadcastShutDown(CMD_RESET)
changed := a.players.ResetNonLeaders()
a.sockets.ClearOfflineLocations()
for _, player := range changed {
Expand Down
2 changes: 2 additions & 0 deletions api/admin_socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const (
AdminEventSnapshot = "snapshot"
AdminEventUpsert = "upsert"
AdminEventFlag = "flag"
AdminEventShutdown = "shutdown" // server is shutting down gracefully; admin should reconnect
AdminEventReset = "reset" // game was reset; admin should reconnect
)

type AdminSocketMessage struct {
Expand Down
6 changes: 6 additions & 0 deletions api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@ func TestAdminResetClearsOfflineLocationsButPreservesActiveCoordinates(t *testin
if coordinate := sockets.hub.coordinates[activeID]; coordinate != activeCoordinate {
t.Errorf("active coordinate after reset = %#v, want %#v", coordinate, activeCoordinate)
}
// ServeReset now broadcasts CMD_RESET before wiping state so that
// connected players know to re-register. Consume that message first.
resetMsg := receiveTestMessage(t, viewer)
if resetMsg.Command != CMD_RESET {
t.Errorf("expected reset signal first, got command = %q", resetMsg.Command)
}
message := receiveTestMessage(t, viewer)
if message.Command != CMD_REMOVE || message.Data != string(offlineID) {
t.Errorf("Admin reset location removal = %#v", message)
Expand Down
2 changes: 2 additions & 0 deletions api/etc.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ 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
CMD_RESET = "reset" // inform clients of server reset

// player type
TypeHidden PlayerType = 0
Expand Down
22 changes: 21 additions & 1 deletion api/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
Expand All @@ -61,8 +68,11 @@ func (h *Hub) Run() {
case done := <-h.clearOffline:
h.clearOfflineLocations()
close(done)
case event := <-h.shutdown:
h.broadcastShutDown(event.command)
close(event.done)
}
}
}
}

func isPrivateMapRole(playerType PlayerType) bool {
Expand Down Expand Up @@ -420,3 +430,13 @@ 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 {
h.enqueue(connection, message)
}
}
9 changes: 9 additions & 0 deletions api/socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ type Sockets struct {
hub *Hub
}

// BroadcastShutDown signals every client..
// CMD_RESET keeps socket open so players can re-register.
// CMD_SHUTDOWN closes everything.
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...)
Expand Down
136 changes: 135 additions & 1 deletion frontend/src/app/core/credentials.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
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', () => {
Expand All @@ -10,3 +14,133 @@ describe('readCookie', () => {
expect(readCookie('theme=dark', 'id')).toBe('');
});
});

describe('CredentialsService', () => {
let service: CredentialsService;
let mockDocument: { cookie: string };
let mockStorage: Record<string, string>;
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; theme=dark';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't support themes in our app.


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();
});
});
27 changes: 27 additions & 0 deletions frontend/src/app/core/credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`;
}
}
2 changes: 1 addition & 1 deletion frontend/src/app/core/game.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface LivePlayer {

export interface SocketMessage {
coordinate?: Coordinate;
command: 'inform' | 'move' | 'remove' | 'state' | string;
command: 'inform' | 'move' | 'remove' | 'state' | 'shutdown' | 'reset' | string;
data: string;
}

Expand Down
88 changes: 88 additions & 0 deletions frontend/src/app/core/sockets/game-socket.service.spec.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests are kinda brittle, because they use hardcoded number for the delays, and the delay between retries isn't documented. This means if the delay was changed or was changed to an exponential backoff, then it would cause the tests to fail. I feel like they could be moved into a const array, similar to the websocket service.

But units tests amirite?

Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,92 @@ describe('GameSocketService', () => {
expect(service.state()).toBe('error');
expect(service.status()).toContain('Register as admin again in this browser');
});

it('reconnects after two failures and expires the session after the third', () => {
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);
vi.advanceTimersByTime(1000);
MockGameWebSocket.instances[1].serverClose(false);
vi.advanceTimersByTime(2000);

expect(service.sessionExpired()).toBe(false);
expect(service.state()).toBe('connecting');
expect(MockGameWebSocket.instances).toHaveLength(3);
expect(onSessionExpired).not.toHaveBeenCalled();

MockGameWebSocket.instances[2].serverClose(false);
vi.runAllTimers();

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('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);
vi.advanceTimersByTime(1000);
MockGameWebSocket.instances[1].serverClose(false);
vi.advanceTimersByTime(2000);

MockGameWebSocket.instances[2].open();
MockGameWebSocket.instances[2].serverClose(false);
vi.advanceTimersByTime(4000);
MockGameWebSocket.instances[3].serverClose(false);
vi.runAllTimers();

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);
vi.advanceTimersByTime(1000);
MockGameWebSocket.instances[1].serverClose(false);
vi.advanceTimersByTime(2000);
MockGameWebSocket.instances[2].serverClose(false);
vi.runAllTimers();

expect(service.sessionExpired()).toBe(false);
expect(MockGameWebSocket.instances.length).toBeGreaterThan(3);
});

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);
vi.advanceTimersByTime(1000);
MockGameWebSocket.instances[1].serverClose(false);
vi.advanceTimersByTime(2000);
MockGameWebSocket.instances[2].serverClose(false);
vi.runAllTimers();

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);
});
});
Loading