diff --git a/.changeset/assignment-alternatives.md b/.changeset/assignment-alternatives.md new file mode 100644 index 00000000..3aea9fc8 --- /dev/null +++ b/.changeset/assignment-alternatives.md @@ -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, + }) +); +``` diff --git a/lib/@empirica/core/src/admin/classic/classic.ts b/lib/@empirica/core/src/admin/classic/classic.ts index 4a51eecc..5ac4a2ae 100644 --- a/lib/@empirica/core/src/admin/classic/classic.ts +++ b/lib/@empirica/core/src/admin/classic/classic.ts @@ -28,27 +28,65 @@ 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({ @@ -56,6 +94,8 @@ export function Classic({ disableIntroCheck, disableGameCreation, disableBatchAutoend = false, + preferUnderassignedGames, + neverOverbookGames, }: ClassicConfig = {}) { return function (_: ListenersCollector) { const online = new Map(); @@ -80,7 +120,7 @@ export function Classic({ continue; } - let availableGames = []; + let availableGames: Game[] = []; for (const game of batch.games) { if ( !game.hasStarted && @@ -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; @@ -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]); } } @@ -535,7 +616,7 @@ export function Classic({ return; } - await await assignplayer(ctx, player); + await assignplayer(ctx, player); } ); diff --git a/tests/stress/experiment/server/src/index.js b/tests/stress/experiment/server/src/index.js index 54de501e..3bbb9fea 100644 --- a/tests/stress/experiment/server/src/index.js +++ b/tests/stress/experiment/server/src/index.js @@ -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 (_) { diff --git a/tests/stress/tests/admin.js b/tests/stress/tests/admin.js index c2a0fa77..f4dedf68 100644 --- a/tests/stress/tests/admin.js +++ b/tests/stress/tests/admin.js @@ -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. diff --git a/tests/stress/tests/assignment.spec.js b/tests/stress/tests/assignment.spec.js index afdf639f..8626ce9a 100644 --- a/tests/stress/tests/assignment.spec.js +++ b/tests/stress/tests/assignment.spec.js @@ -2,7 +2,7 @@ /// const { test } = require("@playwright/test"); -import { adminNewBatch, quickGame } from "./admin"; +import { adminNewBatch, quickGame, quickMultiGame } from "./admin"; import { Context } from "./context"; import { clickReplay, @@ -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 }) => {