Skip to content

Commit

Permalink
Merge pull request #172 from TNG/spec_mode
Browse files Browse the repository at this point in the history
Spectator mode
  • Loading branch information
ChristophNiehoff authored Jan 17, 2022
2 parents 082143e + ba84b1b commit 033e894
Show file tree
Hide file tree
Showing 11 changed files with 170 additions and 57 deletions.
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',
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

0 comments on commit 033e894

Please sign in to comment.