Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spectator mode #172

Merged
merged 4 commits into from
Jan 17, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ The docker-compose setup starts two container:
![docker-compose setup](docs/docker-setup.svg)

## TODO
* Spectator mode
* UI fixes (optimizations, smaller screens)
* Optimize the card sprite sheet (can look at SVGs)
* Improve test coverage, write tests for possible game states and moves
Expand Down
42 changes: 24 additions & 18 deletions src/client/components/board/board.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import './board.css';
import request from 'superagent';
import Status from '../status/status';
import { getDealtCard } from '../../../utils/utils';
import { API_PORT, MODEL_TYPE_IMAGE } from '../../../utils/constants';
import {
API_PORT,
MODEL_TYPE_IMAGE,
SPECTATOR,
} from '../../../utils/constants';
import LicenseAttribution from '../license/licenseAttribution';

class Board extends React.Component {
Expand Down Expand Up @@ -57,15 +61,15 @@ class Board extends React.Component {
try {
return await request
.get(`${this.apiBase}/game/${this.props.matchID}/${endpoint}`)
.auth(this.props.playerID, this.props.credentials);
.auth(this.props.playerID ?? SPECTATOR, this.props.credentials);
} catch (err) {
console.error(err);
}
}

async updateNames() {
const g = await this.apiGetRequest('players');
g.body.players.forEach((p) => {
g?.body.players.forEach((p) => {
if (typeof p.name !== 'undefined') {
this.updateName(p.id, p.name);
}
Expand All @@ -75,7 +79,7 @@ class Board extends React.Component {
async updateModel() {
const r = await this.apiGetRequest('model');

const model = r.body;
const model = r?.body;

this.setState({
...this.state,
Expand Down Expand Up @@ -105,7 +109,7 @@ class Board extends React.Component {
<div>
{this.props.G.modelType === MODEL_TYPE_IMAGE ? (
<ImageModel
playerID={this.props.playerID}
playerID={this.props.playerID ?? SPECTATOR}
credentials={this.props.credentials}
matchID={this.props.matchID}
/>
Expand Down Expand Up @@ -133,25 +137,27 @@ class Board extends React.Component {
isInThreatStage={isInThreatStage}
/>
</div>
<Deck
cards={this.props.G.players[this.props.playerID]}
suit={this.props.G.suit}
/* phase replaced with isInThreatStage. active players is null when not */
isInThreatStage={isInThreatStage}
round={this.props.G.round}
current={current}
active={active}
onCardSelect={(e) => this.props.moves.draw(e)}
startingCard={this.props.G.startingCard} // <=== This is still missing i.e. undeifned
gameMode={this.props.G.gameMode}
/>
{this.props.playerID && (
<Deck
cards={this.props.G.players[this.props.playerID]}
suit={this.props.G.suit}
/* phase replaced with isInThreatStage. active players is null when not */
isInThreatStage={isInThreatStage}
round={this.props.G.round}
current={current}
active={active}
onCardSelect={(e) => this.props.moves.draw(e)}
startingCard={this.props.G.startingCard} // <=== This is still missing i.e. undeifned
gameMode={this.props.G.gameMode}
/>
)}
</div>
<LicenseAttribution gameMode={this.props.G.gameMode} />
</div>
<Sidebar
G={this.props.G}
ctx={this.props.ctx}
playerID={this.props.playerID}
playerID={this.props.playerID ?? SPECTATOR}
matchID={this.props.matchID}
moves={this.props.moves}
isInThreatStage={isInThreatStage}
Expand Down
4 changes: 3 additions & 1 deletion src/client/components/sidebar/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Footer from '../footer/footer';
import {
MODEL_TYPE_DEFAULT,
MODEL_TYPE_THREAT_DRAGON,
SPECTATOR,
} from '../../../utils/constants';

class Sidebar extends React.Component {
Expand Down Expand Up @@ -38,7 +39,8 @@ class Sidebar extends React.Component {
let dealtCard = getDealtCard(this.props.G);
const isLastToPass =
this.props.G.passed.length === this.props.ctx.numPlayers - 1 &&
!this.props.G.passed.includes(this.props.playerID);
!this.props.G.passed.includes(this.props.playerID) &&
this.props.playerID !== SPECTATOR;

return (
<div className="side-bar">
Expand Down
30 changes: 16 additions & 14 deletions src/client/components/threatbar/threatbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,22 @@ class Threatbar extends React.Component {
<FontAwesomeIcon style={{ float: 'right' }} icon={faBolt} />
</CardHeader>
<CardBody className="threat-container">
<Button
color="primary"
size="lg"
block
disabled={
this.props.G.selectedComponent === '' ||
!this.props.isInThreatStage ||
this.props.G.passed.includes(this.props.playerID) ||
!this.props.active
}
onClick={() => this.props.moves.toggleModal()}
>
<FontAwesomeIcon icon={faPlus} /> Add Threat
</Button>
{this.props.playerID && (
<Button
color="primary"
size="lg"
block
disabled={
this.props.G.selectedComponent === '' ||
!this.props.isInThreatStage ||
this.props.G.passed.includes(this.props.playerID) ||
!this.props.active
}
onClick={() => this.props.moves.toggleModal()}
>
<FontAwesomeIcon icon={faPlus} /> Add Threat
</Button>
)}
<div hidden={component !== null && component.type !== 'tm.Flow'}>
<hr />
<Card>
Expand Down
5 changes: 3 additions & 2 deletions src/client/pages/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { Client } from 'boardgame.io/react';
import Board from '../components/board/board';
import { ElevationOfPrivilege } from '../../game/eop';
import { SERVER_PORT } from '../../utils/constants';
import { SERVER_PORT, SPECTATOR } from '../../utils/constants';
import { SocketIO } from 'boardgame.io/multiplayer';
import '../styles/cornucopia_cards.css';
import '../styles/cards.css';
Expand Down Expand Up @@ -46,12 +46,13 @@ class App extends React.Component {
}

render() {
const playerId = this.state.id.toString();
return (
<div className="player-container">
<EOP
matchID={this.state.game}
credentials={this.state.secret}
playerID={this.state.id + ''}
playerID={playerId === SPECTATOR ? undefined : playerId}
/>
<div className="cornucopiacard"></div>
</div>
Expand Down
29 changes: 27 additions & 2 deletions src/client/pages/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
MODEL_TYPE_THREAT_DRAGON,
MODEL_TYPE_IMAGE,
MODEL_TYPE_DEFAULT,
SPECTATOR,
} from '../../utils/constants';
import { getTypeString } from '../../utils/utils';
import Footer from '../components/footer/footer';
Expand All @@ -53,6 +54,7 @@ class Create extends React.Component {
matchID: '',
names: initialPlayerNames,
secret: initialSecrets,
spectatorSecret: ``,
creating: false,
created: false,
modelType: MODEL_TYPE_DEFAULT,
Expand Down Expand Up @@ -132,6 +134,10 @@ class Create extends React.Component {
});
}

this.setState({
spectatorSecret: r.spectatorCredential,
});

this.setState({
...this.state,
matchID: gameId,
Expand Down Expand Up @@ -218,8 +224,12 @@ class Create extends React.Component {
});
}

url(i) {
return `${window.location.origin}/${this.state.matchID}/${i}/${this.state.secret[i]}`;
url(playerId) {
const secret =
playerId === SPECTATOR
? this.state.spectatorSecret
: this.state.secret[playerId];
return `${window.location.origin}/${this.state.matchID}/${playerId}/${secret}`;
}

formatAllLinks() {
Expand Down Expand Up @@ -488,6 +498,21 @@ class Create extends React.Component {
</td>
</tr>
))}
<tr key="spectator" className="spectator-row">
<td className="c-td-name">Spectator</td>
<td>
<a
href={`${this.url(SPECTATOR)}`}
target="_blank"
rel="noopener noreferrer"
>
{this.url(SPECTATOR)}
</a>
</td>
<td>
<CopyButton text={this.url(SPECTATOR)} />
</td>
</tr>
</tbody>
</Table>
<hr />
Expand Down
4 changes: 4 additions & 0 deletions src/client/styles/create.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ table {
text-overflow: ellipsis;
white-space: nowrap;
}

.spectator-row {
border-top: 5px double #eee;
}
60 changes: 57 additions & 3 deletions src/server/__tests__/server.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import request from 'supertest';
import { GAMEMODE_EOP, MODEL_TYPE_THREAT_DRAGON } from '../../utils/constants';
import {
GAMEMODE_EOP,
MODEL_TYPE_DEFAULT,
MODEL_TYPE_THREAT_DRAGON,
SPECTATOR,
} from '../../utils/constants';
import {
gameServer,
gameServerHandle,
Expand Down Expand Up @@ -384,6 +389,7 @@ describe('authentificaton', () => {
const endpoints = ['players', 'model', 'download', 'download/text'];
let matchID = null;
let credentials = null;
let spectatorCredential = null;

beforeAll(async () => {
// first create game
Expand All @@ -392,17 +398,19 @@ describe('authentificaton', () => {
let response = await request(publicApiServer.callback())
.post('/game/create')
.field('players', players)
.field('names[]', ['P1', 'P2', 'P3']);
.field('names[]', ['P1', 'P2', 'P3'])
.field('modelType', MODEL_TYPE_DEFAULT);

expect(response.body.game).toBeDefined();
expect(response.body.credentials.length).toBe(players);

matchID = response.body.game;
credentials = response.body.credentials;
spectatorCredential = response.body.spectatorCredential;
});

it.each(endpoints)(
'returns an error if no authentification is provided to %s',
'returns an error if no authentication is provided to %s',
async (endpoint) => {
// Try players

Expand Down Expand Up @@ -458,11 +466,57 @@ describe('authentificaton', () => {
.get(`/game/${matchID}/${endpoint}`)
.set(
'Authorization',
// missing 'Basic ' prefix
Buffer.from(`0:${credentials[0]}`).toString('base64'),
);
expect(response.status).toBe(403);
},
);

it.each(endpoints)(
'is successful for correct credentials provided to %s',
async (endpoint) => {
// Try players

let response = await request(publicApiServer.callback())
.get(`/game/${matchID}/${endpoint}`)
.set(
'Authorization',
'Basic ' + Buffer.from(`0:${credentials[0]}`).toString('base64'),
);
expect(response.status).not.toBe(403);
},
);

it.each(endpoints)(
'is successful for correct spectator credentials provided to %s',
ChristophNiehoff marked this conversation as resolved.
Show resolved Hide resolved
async (endpoint) => {
let response = await request(publicApiServer.callback())
.get(`/game/${matchID}/${endpoint}`)
.set(
'Authorization',
'Basic ' +
Buffer.from(`${SPECTATOR}:${spectatorCredential}`).toString(
'base64',
),
);
expect(response.status).not.toBe(403);
},
);

it.each(endpoints)(
'rejects wrong spectator credentials provided to %s',
async (endpoint) => {
let response = await request(publicApiServer.callback())
.get(`/game/${matchID}/${endpoint}`)
.set(
'Authorization',
'Basic ' +
Buffer.from(`${SPECTATOR}:wrongCredentials`).toString('base64'),
);
expect(response.status).toBe(403);
},
);
});

afterAll(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/server/endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import {
isGameModeCornucopia,
logEvent,
} from '../utils/utils';
import { v4 as uuidv4 } from 'uuid';

export const createGame = (gameServer) => async (ctx) => {
const spectatorCredential = uuidv4();

try {
// Create game
const r = await request
Expand All @@ -31,6 +34,7 @@ export const createGame = (gameServer) => async (ctx) => {
turnDuration: ctx.request.body.turnDuration,
gameMode: ctx.request.body.gameMode,
modelType: ctx.request.body.modelType,
spectatorCredential,
},
});

Expand Down Expand Up @@ -92,6 +96,7 @@ export const createGame = (gameServer) => async (ctx) => {
ctx.body = {
game: gameId,
credentials,
spectatorCredential,
};
} catch (err) {
// Maybe this error could be more specific?
Expand Down
Loading