const Player = require('./Player.js');
const Timer = require('./Timer.js');
const Utils = require('./Utils.js');
const Table = require('./Table.js');
const fs = require('fs-extra');
const path = require('path');
const CircularJSON = require('./circularjson.js');
/** A group of players playing in a {@link Period}. */
class Group {
/**
* Create a new Group.
*
* @param {String} id The id of this group.
* @param {Period} period The period this group belongs to.
*/
constructor(id, period) {
/**
* this group's id
* @type {String}
*/
this.id = id;
/**
* Each Group belongs to a single Period.
* @type {Period}
*/
this.period = period;
/**
* a list of the players in this group.
* @type array
* @default []
*/
this.players = [];
/**
* whether or not all players in this group have been created yet.
* @type boolean
* @default false
*/
this.allPlayersCreated = false;
/**
* 'outputHide' fields are not included in output.
* @type array
* @default []
*/
this.outputHide = [];
/**
* 'outputHideAuto' fields are not included in output.
* @type {String[]}
*/
this.outputHideAuto = ['stage', 'status', 'outputHide', 'outputHideAuto', 'players', 'stageTimer', 'stageGraceTimer', 'period', 'tables', 'type', 'stageIndex', 'stageEndedIndex'];
/**
* @type array
* @default []
*/
this.tables = [];
/**
* @type number
* @default 0
*/
this.stageIndex = 0;
this.stageStartedIndex = -1;
this.stageEndedIndex = -1;
}
/**
* Returns the stage that this group is currently in.
*/
stage() {
return this.app().stages[this.stageIndex];
}
/**
* Loads the group from a given set of data.
*
* CALLED FROM:
* - {@link Session#load}
*
* @param {type} json The JSON data describing the group.
* @param {type} session The session to which the group belongs.
* @return {Group} The group.
*/
static load(json, session, data) {
var app = session.apps[json.appIndex-1];
var period = app.periods[json.periodId-1];
var id = json.id;
var newGroup = new Group(id, period);
// A group saved earlier: this record replaces it, but its players and tables have
// records of their own.
if (period.groups.length > id-1) {
var curGroup = period.groups[id-1];
newGroup.players = curGroup.players;
for (const name of curGroup.tables) {
newGroup[name] = curGroup[name];
newGroup[name].context = newGroup;
}
}
Utils.copySavedFields(newGroup, json);
// Its stage timer, if any, is in the stageTimer* fields: restoreStageTimer starts it
// once all records are read, as a later record may replace this group.
period.groups[id-1] = newGroup;
}
/**
* Find the player with the given participant ID.
* @param {String} id the given id.
* @return {type} the player where player.participant.id == id.
*/
playerByParticipantId(id) {
for (var i=0; i<this.players.length; i++) {
if (this.players[i].participant.id === id) {
return this.players[i];
}
}
return null;
}
/**
* playerWithParticipant - description
*
* @param {type} participant description
* @return {type} description
*/
playerWithParticipant(participant) {
return this.playerByParticipantId(participant.id);
}
slowestPlayers() {
var out = [];
var minStageIndex = null;
for (var i in this.players) {
var part = this.players[i];
if (minStageIndex === null || part.stageIndex <= minStageIndex) {
if (minStageIndex === null || part.stageIndex < minStageIndex) {
minStageIndex = part.stageIndex;
out = [];
}
out.push(part);
}
}
return out;
}
addTable(name) {
this[name] = new Table.new(name, 'this.context.emit', this, this.roomId(), this.session());
this.tables.push(name);
}
playerWithId(id) {
for (var i=0; i<this.players.length; i++) {
if (this.players[i].idInGroup === id) {
return this.players[i];
}
}
return null;
}
jt() {
return this.session().jt;
}
canProcessMessage() {
return true;
}
/**
* old - description
*
* @return {type} description
*/
old() {
return this.app().previousGroup(this);
}
/** The group with this group's id in period n (from 1) of this app, or null if there is none yet. */
inPeriod(n) {
const period = this.app().periods[n - 1];
return period == null ? null : (period.groups.find((g) => g.id === this.id) || null);
}
/** The groups with this group's id in this app's periods, from the first to this one. */
inAllPeriods() {
const out = [];
for (let n = 1; n <= this.period.id; n++) {
const group = this.inPeriod(n);
if (group != null) {
out.push(group);
}
}
return out;
}
/** The groups with this group's id in this app's periods before this one, first first. */
inPreviousPeriods() {
return this.inAllPeriods().filter((g) => g !== this);
}
/**
* playersExcept - description
*
* @param {type} player description
* @return {type} description
*/
playersExcept(player) {
return this.playersExceptIds([player.id]);
}
playersExceptIds(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
var out = [];
for (var i=0; i<this.players.length; i++) {
if (!ids.includes(this.players[i].participant.id)) {
out.push(this.players[i]);
}
}
return out;
}
clearStageTimer() {
//console.log('clearing stage timer');
if (this.stageTimer !== undefined) {
this.stageTimer.clear();
this.stageTimer = undefined;
}
if (this.stageGraceTimer !== undefined) {
this.stageGraceTimer.clear();
this.stageGraceTimer = undefined;
}
}
/**
* isFinished - description
*
* @return {type} description
*/
isFinished() {
for (var i=0; i<this.players.length; i++) {
if (!this.players[i].isFinished()) {
return false;
}
}
return true;
}
/*
* outputFields - description
*
* @return {type} description
*/
outputFields() {
var fields = [];
for (var prop in this) {
if (
!Utils.isFunction(this[prop]) &&
!this.outputHide.includes(prop) &&
!this.outputHideAuto.includes(prop) &&
!this.tables.includes(prop)
)
fields.push(prop);
}
return fields;
}
player(id) {
return Utils.findByIdWOJQ(this.players, id);
}
/**
* shell - description
*
* @return {type} description
*/
shellWithParent() {
var out = {};
var fields = this.outputFields();
for (var f in fields) {
var field = fields[f];
out[field] = this[field];
}
out.period = this.period.shellWithParent();
out.numPlayers = this.players.length;
if (this.stageTimer !== undefined) {
out.stageTimerStart = this.stageTimer.timeStarted;
out.stageTimerDuration = this.stageTimer.duration;
} else {
out.timer = 'none';
}
out.tables = this.tables;
for (var i in this.tables) {
var name = this.tables[i];
if (this[name] != null) {
out[name] = this[name].shell();
}
}
return out;
}
shellForPlayerUpdate() {
var out = this.shellWithChildren();
out.period = this.period.shellWithParent();
return out;
}
timeInStage() {
if (this.stageTimer == null) {
return 0;
}
return this.stageTimer.state().timeElapsed;
}
/**
* emitUpdate - description
*
* @return {type} description
*/
emitUpdate() {
this.emit('groupUpdate', this.shellWithChildren());
}
/**
* Returns the sum of "field" over all players in the group.
*
* @param {String} field
*/
sum(field) {
return Utils.sum(this.players, field);
}
/**
* Emit the given message to subscriber's of this group.
*
* @param {type} msgTitle The title of the message.
* @param {type} msgData The data of the message.
*/
emit(msgTitle, msgData) {
this.session().io().to(this.roomId()).emit(msgTitle, CircularJSON.stringify(msgData));
}
/**
* session - description
*
* @return {@link Session} The session that this group belongs to.
*/
session() {
return this.period.session();
}
/**
* roomId - description
*
* @return {type} description
*/
roomId() {
return this.period.roomId() + '_group_' + this.id;
}
/*
* moveToNextStage - description
*
* @return {type} description
*/
moveToNextStage() {
for (var i=0; i<this.players.length; i++) {
this.app().playerMoveToNextStage(this.players[i]);
}
}
showStatus() {
console.log('Group ' + this.id + ': stageIndex=' + this.stageIndex + ', stageEndedIndex=' + this.stageEndedIndex);
this.session().printStatuses();
}
/**
* Returns the app this group is in.
*
* @return {App} The app.
*/
app() {
return this.period.app;
}
/*
* Add client to this group.<br>
* 1. The client joins this group's channel.
*
* @param {type} client description
*/
addClient(client) {
client.socket.join(this.roomId());
}
/**
* shellAll - description
*
* @return {type} description
*/
shellWithChildren() {
var out = {};
var fields = this.outputFields();
for (var f in fields) {
var field = fields[f];
out[field] = this[field];
}
out.period = this.period.id;
out.players = [];
for (var i in this.players) {
out.players[i] = this.players[i].shellWithChildren();
}
if (this.stageTimer !== undefined) {
out.stageTimerStart = this.stageTimer.timeStarted;
out.stageTimerDuration = this.stageTimer.duration;
out.stageTimerTimeLeft = this.stageTimer.timeLeft;
}
out.tables = this.tables;
for (var i in this.tables) {
var name = this.tables[i];
if (this[name] !== undefined) {
out[name] = this[name].shell();
}
}
return out;
}
/*
* getOutputDir - description
*
* @return {type} description
*/
getOutputDir() {
return this.period.getOutputDir() + '/groups/' + this.id;
}
/**
* shellLocal - description
*
* @return {type} description
*/
shell() {
var out = {};
var fields = this.outputFields();
for (var f in fields) {
var field = fields[f];
out[field] = this[field];
}
out.tables = this.tables;
if (this.stageTimer !== undefined) {
out.stageTimerStart = this.stageTimer.timeStarted;
out.stageTimerDuration = this.stageTimer.duration;
out.stageTimerTimeLeft = this.stageTimer.timeLeft;
out.stageTimerStageIndex = this.stageTimer.stageIndex;
out.stageTimerRunning = this.stageTimer.running;
}
out.periodId = this.period.id;
out.appIndex = this.app().indexInSession();
return out;
}
/**
* save - description
*/
save() {
try {
this.session().jt.log('Group.save: ' + this.roomId());
var toSave = this.shell();
this.session().saveDataFS(toSave, 'GROUP');
for (var i=0; i<this.tables.length; i++) {
var table = this[this.tables[i]];
if (table !== undefined) {
this[this.tables[i]].save();
}
}
} catch (err) {
console.log('Error saving group ' + this.id + ': ' + err);
console.log(err.stack);
}
}
canPlayersStart(stage) {
if (this.stageStartedIndex >= stage.indexInApp()) {
return true;
}
// Waiting for all groups, or for a group formed by arrival to fill: only once the
// group has started (see startStage).
if (stage.waitForAllGroups || (stage.app.groupByArrival && !this.allPlayersCreated)) {
return false;
}
// If do not need to wait for all players, return true.
if (!stage.waitToStart) {
return true;
}
// If any player is not ready, return false.
for (let p in this.players) {
let player = this.players[p];
if (!player.isReady(stage.indexInApp())) {
return false;
}
}
return true;
}
canPlayersEnd(stage) {
// If Group has already finished, do not allow players to finish.
if (this.stageEndedIndex >= stage.indexInApp()) {
return false;
}
// If do not need to wait for all players, return true.
if (!stage.waitToEnd) {
return true;
}
// If any player is not finished playing, return false.
for (let p in this.players) {
let player = this.players[p];
if (!player.isFinishedStage(stage)) {
return false;
}
}
// Otherwise, return true.
return true;
}
// checkIfWaitingToEnd(stage, endPlayers, canParticipate) {
// console.log('Group.checkIfWaitingToEnd: ' + this.roomId());
// this.showStatus();
// if (canParticipate == null) {
// canParticipate = true;
// }
// let group = this;
// // Wait for players to submit their forms.
// var waitingForPlayers = false;
// if (!canParticipate) {
// this.attemptToStartNextStage();
// return;
// }
// if (stage.waitOnTimerEnd) {
// for (var p in group.players) {
// var player = group.players[p];
// // If player is in an earlier stage, wait.
// if (player.stage.indexInApp() < stage.indexInApp()) {
// waitingForPlayers = true;
// } else if (player.stage.indexInApp() > stage.indexInApp()) {
// // If player is past this stage, proceed.
// } else {
// // If player is in this stage and not finished...
// if (!player.isFinished()) {
// // If any clients are connected, let player finish via call to "endStage".
// if (player.participant.clients.length > 0) {
// waitingForPlayers = true;
// if (endPlayers) {
// player.emit('endStage', player.shellWithParent());
// }
// }
// // If not, end player immediately.
// else {
// if (endPlayers) {
// console.log('No connected clients for ' + player.id + ', ending immediately.');
// player.attemptToEndStage(false);
// } else {
// waitingForPlayers = true;
// }
// }
// }
// }
// }
// }
// // If not waiting for any players, proceed without waiting for players to submit their forms.
// if (!waitingForPlayers) {
// for (var p in group.players) {
// var player = group.players[p];
// if (player.stage.id === stage.id && player.status !== 'done') {
// player.justEndStage();
// }
// }
// console.log(this.jt().settings.getConsoleTimeStamp() + ' END - GROUP : ' + stage.id + ', ' + group.roomId());
// stage.groupEnd(group);
// this.attemptToStartNextStage();
// } else {
// // debugger;
// }
// }
startStage(stage) {
if (!stage.canGroupParticipate(this)) {
this.endStage();
}
if (!stage.canGroupStart(this)) {
return;
}
// The last group to arrive at a stage that waits for all groups starts them all.
if (stage.waitForAllGroups && !this.period.allGroupsStarted.includes(stage.indexInApp())) {
this.period.allGroupsStarted.push(stage.indexInApp());
try {
stage.allGroupsStart(this.period);
} catch (err) {
console.log(err.stack);
}
for (const group of this.period.groups) {
group.startStage(stage);
}
return;
}
this.stageStartedIndex++;
this.stageIndex = stage.indexInApp();
let groupDuration = stage.getGroupDuration(this);
if (groupDuration > 0) {
this.startStageTimer(stage, groupDuration*1000);
}
try {
console.log(this.jt().settings.getConsoleTimeStamp() + ' START - GROUP : ' + stage.id + ', ' + this.roomId());
stage.groupStart(this);
} catch (err) {
console.log(err.stack);
}
try {
this.save();
} catch (err) {}
for (var p in this.players) {
try {
this.players[p].startStage(stage);
} catch (err) {}
}
}
/**
* For a group loaded from a session's file (see Group.load), starts the stage timer it
* had. The stage's time ran until jtree stopped (lastTimeOn) and stood still while it was
* off; time already up ends the stage straight away.
*/
restoreStageTimer(lastTimeOn) {
const saved = {
start: this.stageTimerStart,
timeLeft: this.stageTimerTimeLeft,
stageIndex: this.stageTimerStageIndex,
running: this.stageTimerRunning,
};
for (const field of ['stageTimerStart', 'stageTimerDuration', 'stageTimerTimeLeft', 'stageTimerStageIndex', 'stageTimerRunning', 'stageTimerCallback']) {
delete this[field];
}
if (saved.start === undefined || saved.stageIndex === undefined || this.stageEndedIndex >= saved.stageIndex) {
return;
}
let timeLeft = saved.timeLeft;
if (saved.running !== false) {
timeLeft -= lastTimeOn - new Date(saved.start).getTime();
}
this.startStageTimer(this.app().stages[saved.stageIndex], Math.max(timeLeft, 1));
if (!this.session().isRunning) {
this.stageTimer.pause();
}
}
/** Starts the timer that ends stage for this group (see forceEndStage) in ms milliseconds. */
startStageTimer(stage, ms) {
this.stageTimer = new Timer.new(function() {
this.session().addMessageToStartOfQueue(this, stage, 'forceEndStage');
}.bind(this), ms, stage.indexInApp());
}
/**
* The stage timed out: ends it for players who have not ended it, marking them
* {@link Player#timedOut}.
*/
forceEndStage(stage) {
console.log('Group.forceEndStage: ' + stage.id);
this.clearStageTimer();
for (const player of this.players) {
if (player.stage === stage && ['ready', 'playing'].includes(player.status)) {
player.timedOut = true;
player.emitUpdate2();
}
}
// The stage stays open until the players submit.
if (!stage.endOnTimeout) {
return;
}
this.endStage(stage, true);
// Pages were asked to submit what they have (see waitingForPlayersInStage). Players
// whose page has not after stage.timeoutGrace seconds are ended here.
if (this.stageEndedIndex < stage.indexInApp() && stage.timeoutGrace != null) {
if (stage.timeoutGrace > 0) {
this.stageGraceTimer = new Timer.new(function() {
this.session().addMessageToStartOfQueue(this, stage, 'endStageForSilentPlayers');
}.bind(this), stage.timeoutGrace * 1000, stage.indexInApp());
} else {
this.endStageForSilentPlayers(stage);
}
}
}
/** Ends stage for players still in it, after it timed out and their page did not submit. */
endStageForSilentPlayers(stage) {
this.stageGraceTimer = undefined;
for (const player of this.players) {
if (player.stage === stage && !player.isFinished()) {
console.log('No page submitted for ' + player.roomId() + ' after stage ' + stage.id + ' timed out, ending it.');
player.endStage(true);
}
}
}
endStage(stage, forcePlayersToEnd) {
if (forcePlayersToEnd == null) {
forcePlayersToEnd = false;
}
if (!stage.canGroupEnd(this, forcePlayersToEnd)) {
return;
}
// If waiting for any players, stop.
if (this.waitingForPlayersInStage(stage, forcePlayersToEnd)) {
return;
}
this.clearStageTimer();
// For a stage that waits for everyone, players move on below, after groupEnd (and
// periodEnd), so that what those do is done before anyone's next stage starts.
for (var p in this.players) {
var player = this.players[p];
if (player.stage.id === stage.id && player.status !== 'finished') {
player.endStage(false, !stage.waitToEnd);
}
}
console.log(this.jt().settings.getConsoleTimeStamp() + ' END - GROUP : ' + stage.id + ', ' + this.roomId());
this.stageEndedIndex = stage.indexInApp();
try {
stage.groupEnd(this);
} catch (err) {
console.log(err.stack);
}
if (stage.indexInApp() === this.app().stages.length - 1) {
this.period.groupEnded();
}
if (stage.waitToEnd) {
for (var p in this.players) {
let player = this.players[p];
if (player.stageIndex === stage.indexInApp()) {
player.moveToNextStage();
}
}
}
this.stageIndex = stage.indexInApp() + 1;
if (this.stageIndex < this.app().stages.length) {
// move group (and all its players) to next stage.
this.startStage(this.stage());
} else {
// move all players to next period.
for (var p in this.players) {
this.players[p].moveToNextStage();
}
}
}
// Check if this group is waiting for players to finish playing the given stage.
// If it is, tell those players to end the stage.
// Return whether or not any player
waitingForPlayersInStage(stage, forcePlayersToEnd) {
let waitingForPlayers = false;
if (stage.waitOnTimerEnd) {
for (var p in this.players) {
var player = this.players[p];
// If player has not started the period yet, or is in an earlier stage, wait.
if (player.stage == null || player.stage.indexInApp() < stage.indexInApp()) {
waitingForPlayers = true;
} else if (player.stage.indexInApp() > stage.indexInApp()) {
// If player is past this stage, proceed.
} else {
// If player is in this stage and not finished...
if (!player.isFinishedStage(stage)) {
waitingForPlayers = true;
if (forcePlayersToEnd) {
// If any clients are connected, let player finish via call to "endStage".
if (player.participant.clients.length > 0) {
player.emit('endStage', player.shellWithParent());
}
// If not, end player immediately.
else {
console.log('No connected clients for ' + player.id + ', ending immediately.');
player.endStage(false);
}
}
}
}
}
}
return waitingForPlayers;
}
}
var exports = module.exports = {};
exports.new = Group;
exports.load = Group.load;