Skip to content

Commit

Permalink
feat: add assignment alternatives (#583)
Browse files Browse the repository at this point in the history
  • Loading branch information
npaton authored Oct 20, 2024
1 parent 69b40d9 commit d99bca1
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 19 deletions.
59 changes: 59 additions & 0 deletions .changeset/assignment-alternatives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
"@empirica/core": minor
---

Add `preferUnderassignedGames` and `neverOverbookGames` assignment options.

The default player assignement algorithm assigns randomly to all games in the
first batch that still has game that haven't started. This algorithm will
notably "overbook" games, meaning that it will assign more players than the
game can handle. This is useful to ensure that games start quickly, while
maintaining good randomization. When the game starts, the players that are not
ready yet (because they are still in the intro steps) are automatically
reassigned to other games, with the same treamtent, if available.

However, in some cases, you may want to avoid overbooking games, or prefer to
assign players to games that are underassigned. This is now possible with the
`preferUnderassignedGames` and `neverOverbookGames` options.

The `preferUnderassignedGames` option will try to assign players to games that
are underassigned, before assigning to games that are already full, resuming
the assignment process as usual if no underassigned games are available, in the
current batch (this option does not try to prefer games that are underassigned
across batches).

The `neverOverbookGames` option will never assign players to games that are
already full. This will push players into the next batches, if no games are
available in the current batch. If no games are available in the next batches,
the player will be sent to exit. This option is a bit more strict than
`preferUnderassignedGames` and it can result in longer waiting times for
players, and potentially game that never start if a player never finishes the
intro steps.

Given the radical nature of the `neverOverbookGames` option, it is recommended
to use `preferUnderassignedGames` option if you do not want the normal behavior
of the assignment algorithm. If you use a single batch,
`preferUnderassignedGames` should fill optimally.

Note that `neverOverbookGames` takes precedence over `preferUnderassignedGames`,
meaning that if both options are set to `true`, `preferUnderassignedGames` will
be ignored.

To apply these options, in `server/src/index.js`, you can add the following
options to the `Classic` function:

```js
ctx.register(
Classic({
preferUnderassignedGames: true,
})
);
```

```js
ctx.register(
Classic({
neverOverbookGames: true,
})
);
```
115 changes: 98 additions & 17 deletions lib/@empirica/core/src/admin/classic/classic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,74 @@ const isStage = z.instanceof(Stage).parse;
const isString = z.string().parse;

export type ClassicConfig = {
// Disables automatic assignment of players on the connection of a new player.
// It is up to the developer to call `game.assignPlayer` when they want to
// assign a player to a game.
/**
* Disables automatic assignment of players on the connection of a new player.
* It is up to the developer to call `game.assignPlayer` when they want to
* assign a player to a game.
*
* @type {boolean}
*/
disableAssignment?: boolean;

// Disable the introDone check (when the players are done with intro steps),
// which normally will check if enough players are ready (done with intro
// steps) to start a game. This means that the game will not start on its own
// after intro steps. It is up to the developer to start the game manually
// with `game.start()`.
// This also disables playerCount checks and overflow from one game to the
// next available game with the same treatment.
/**
* Disable the introDone check (when the players are done with intro steps),
* which normally will check if enough players are ready (done with intro
* steps) to start a game. This means that the game will not start on its own
* after intro steps. It is up to the developer to start the game manually
* with `game.start()`.
*
* This also disables playerCount checks and overflow from one game to the
* next available game with the same treatment.
*
* @type {boolean}
*/
disableIntroCheck?: boolean;

// Disable game creation on new batch.
/**
* Disable game creation on new batch.
*
* @type {boolean}
*/
disableGameCreation?: boolean;

// By default if all existing games are complete, the batch ends.
// This option disables this pattern so that we can leave a batch open indefinitely.
// It enables to spawn new games for people who arrive later, even if all previous games had already finished.
/**
* By default if all existing games are complete, the batch ends.
* This option disables this pattern so that we can leave a batch open
* indefinitely.
* It enables to spawn new games for people who arrive later, even if all
* previous games had already finished.
*
* @type {boolean}
*/
disableBatchAutoend?: boolean;

/**
* If true, players will be assigned to games that still have open slots
* rather than pure random assignment.
*
* @type {boolean}
*/
preferUnderassignedGames?: boolean;

/**
* If a game is full, don't assign more players to it.
* If there is a subsequent batch that still has open slots, it will still try
* to assign players to those games.
* If `preferUnderassignedGames` is also set, `neverOverbookGames` will take
* precedence.
*
* @type {boolean}
*/
neverOverbookGames?: boolean;
};

export function Classic({
disableAssignment,
disableIntroCheck,
disableGameCreation,
disableBatchAutoend = false,
preferUnderassignedGames,
neverOverbookGames,
}: ClassicConfig = {}) {
return function (_: ListenersCollector<Context, ClassicKinds>) {
const online = new Map<string, Participant>();
Expand All @@ -80,7 +120,7 @@ export function Classic({
continue;
}

let availableGames = [];
let availableGames: Game[] = [];
for (const game of batch.games) {
if (
!game.hasStarted &&
Expand All @@ -105,8 +145,48 @@ export function Classic({
continue;
}

const game = pickRandom(availableGames);
if (preferUnderassignedGames || neverOverbookGames) {
const filteredGames = availableGames.filter((g) => {
const treatment = factorsSchema.parse(g.get("treatment"));
const playerCount = treatment["playerCount"] as number;
if (!playerCount || typeof playerCount !== "number") {
warn(
"preferUnderassignedGames|neverOverbookGames: no playerCount",
g.id
);
return true;
}

trace(
"playerAssignedCount|neverOverbookGames",
g.players.length,
playerCount
);

return g.players.length < playerCount;
});

if (filteredGames.length === 0) {
if (neverOverbookGames) {
trace("neverOverbookGames: no games available in this batch");
availableGames = [];
} else {
trace(
"preferUnderassignedGames: no empty games in this batch, overbooking"
);
}
} else {
trace(
"preferUnderassignedGames|neverOverbookGames: filtered games:",
filteredGames.length,
"/",
availableGames.length
);
availableGames = filteredGames;
}
}

const game = pickRandom(availableGames);
await game.assignPlayer(player);

return;
Expand Down Expand Up @@ -486,6 +566,7 @@ export function Classic({
for (const plyr of game.players) {
if (!playersIDS.includes(plyr.id)) {
plyr.set("gameID", null);
trace("introDone: unassigning player - REASSIGNING", plyr.id);
await assignplayer(ctx, plyr, [game.id]);
}
}
Expand Down Expand Up @@ -535,7 +616,7 @@ export function Classic({
return;
}

await await assignplayer(ctx, player);
await assignplayer(ctx, player);
}
);

Expand Down
11 changes: 10 additions & 1 deletion tests/stress/experiment/server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,17 @@ setLogLevel(argv["loglevel"] || "info");
classicKinds
);

const preferUnderassignedGames =
process.env.PREFER_UNDERASSIGNED_GAMES === "1";

console.log("preferUnderassignedGames", preferUnderassignedGames);

ctx.register(ClassicLoader);
ctx.register(Classic());
ctx.register(
Classic({
preferUnderassignedGames,
})
);
ctx.register(Lobby());
ctx.register(Empirica);
ctx.register(function (_) {
Expand Down
16 changes: 16 additions & 0 deletions tests/stress/tests/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ export function quickGame(playerCount, roundCount, stageCount, factors = {}) {
]);
}

export function quickMultiGame(
playerCount,
roundCount,
stageCount,
gameCount,
factors = {}
) {
return completeAssignment([
completeTreatment(
"quick",
{ ...factors, playerCount, roundCount, stageCount },
gameCount
),
]);
}

/**
* @param {Object} params The parameters for the new batch.
* @param {string} [params.treatmentName] The name of the treatment to select.
Expand Down
59 changes: 58 additions & 1 deletion tests/stress/tests/assignment.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/// <reference path="./index.d.ts" />

const { test } = require("@playwright/test");
import { adminNewBatch, quickGame } from "./admin";
import { adminNewBatch, quickGame, quickMultiGame } from "./admin";
import { Context } from "./context";
import {
clickReplay,
Expand All @@ -18,6 +18,63 @@ import { sleep } from "./utils";
// test.
test.describe.configure({ mode: "serial" });

// This tests the preferUnderassignedGames option of the Classic loader.
// Since there is not way to start the experiment with this option, it must be
// ran manually. Set the env var PREFER_UNDERASSIGNED_GAMES to 1 before running
// the experiment, then mark this test as "test.only()" and run the tests.
//
// `export PREFER_UNDERASSIGNED_GAMES=1`
//
test.skip("prefer underassigned games", async ({ browser }) => {
const ctx = new Context(browser);

ctx.logMatching(/stage started/);

const gameCount = 3;
const playerCount = 10;
const roundCount = 1;
const stageCount = 1;

await ctx.start();
await ctx.addPlayers(gameCount * playerCount);
ctx.players[0].logWS();

await ctx.applyAdmin(
adminNewBatch({
treatmentConfig: quickMultiGame(
playerCount,
roundCount,
stageCount,
gameCount
),
})
);

const starts = [];
for (const player of ctx.players) {
starts.push(player.apply(playerStart));
// await sleep(1000);
}

await Promise.all(starts);

const submits = [];
for (const player of ctx.players) {
submits.push(player.apply(submitStage));
}

await Promise.all(submits);

const waits = [];
for (const player of ctx.players) {
waits.push(player.apply(waitGameFinished));
}

await Promise.all(waits);

await ctx.close();
});

// This tests whether the player can be reassigned after the first game of the
// player ends.
test("reassignment after game end", async ({ browser }) => {
Expand Down

0 comments on commit d99bca1

Please sign in to comment.