volleyballsimtypes 0.0.492 → 0.0.494
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/data/models/competition.d.ts +7 -1
- package/dist/cjs/src/service/competition/event-draft.d.ts +35 -26
- package/dist/cjs/src/service/competition/event-draft.js +61 -49
- package/dist/cjs/src/service/competition/event-draft.test.js +90 -47
- package/dist/cjs/src/service/player/player-generator.d.ts +1 -1
- package/dist/cjs/src/service/player/player-generator.js +21 -4
- package/dist/esm/src/data/models/competition.d.ts +7 -1
- package/dist/esm/src/service/competition/event-draft.d.ts +35 -26
- package/dist/esm/src/service/competition/event-draft.js +61 -48
- package/dist/esm/src/service/competition/event-draft.test.js +92 -49
- package/dist/esm/src/service/player/player-generator.d.ts +1 -1
- package/dist/esm/src/service/player/player-generator.js +21 -4
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Sequelize from 'sequelize';
|
|
2
2
|
import { Model } from 'sequelize';
|
|
3
|
-
import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
|
|
3
|
+
import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, EventCompetitionAttributes, EventCompetitionId, EventCompetitionModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
|
|
4
4
|
import { Status } from '../common';
|
|
5
5
|
export interface CompetitionAttributes {
|
|
6
6
|
competition_id: string;
|
|
@@ -15,6 +15,8 @@ export interface CompetitionAttributes {
|
|
|
15
15
|
CompetitionTeams?: CompetitionTeamsAttributes[];
|
|
16
16
|
RegionQualifier?: RegionQualifierAttributes;
|
|
17
17
|
NationalCountry?: NationalCountryAttributes;
|
|
18
|
+
/** Set only on an EVENT competition: which event it belongs to, and which stage of it. */
|
|
19
|
+
EventCompetition?: EventCompetitionAttributes;
|
|
18
20
|
UpperPromotionMatches?: PromotionMatchAttributes[];
|
|
19
21
|
LowerPromotionMatches?: PromotionMatchAttributes[];
|
|
20
22
|
}
|
|
@@ -72,6 +74,10 @@ export declare class CompetitionModel extends Model<CompetitionAttributes, Compe
|
|
|
72
74
|
getNationalCountry: Sequelize.HasOneGetAssociationMixin<NationalCountryModel>;
|
|
73
75
|
setNationalCountry: Sequelize.HasOneSetAssociationMixin<NationalCountryModel, NationalCountryId>;
|
|
74
76
|
createNationalCountry: Sequelize.HasOneCreateAssociationMixin<NationalCountryModel>;
|
|
77
|
+
EventCompetition: EventCompetitionModel;
|
|
78
|
+
getEventCompetition: Sequelize.HasOneGetAssociationMixin<EventCompetitionModel>;
|
|
79
|
+
setEventCompetition: Sequelize.HasOneSetAssociationMixin<EventCompetitionModel, EventCompetitionId>;
|
|
80
|
+
createEventCompetition: Sequelize.HasOneCreateAssociationMixin<EventCompetitionModel>;
|
|
75
81
|
UpperPromotionMatches: PromotionMatchModel[];
|
|
76
82
|
getUpperPromotionMatches: Sequelize.HasManyGetAssociationsMixin<PromotionMatchModel>;
|
|
77
83
|
setUpperPromotionMatches: Sequelize.HasManySetAssociationsMixin<PromotionMatchModel, PromotionMatchId>;
|
|
@@ -1,46 +1,55 @@
|
|
|
1
1
|
import { Country } from '../country';
|
|
2
2
|
import { Player, Rarity, Role } from '../player';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
|
|
5
|
+
* read by the view to label it, so it is a named constant rather than a bare string in three repos.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FREE_PACK_ROLE = "ANY";
|
|
8
|
+
/** A pack targets one role, or the free pick. */
|
|
9
|
+
export type DraftRole = Role | typeof FREE_PACK_ROLE;
|
|
10
|
+
/** How many players a role-targeted pack offers. */
|
|
6
11
|
export declare const PACK_SIZE = 4;
|
|
7
12
|
/**
|
|
8
|
-
* The pack sequence, in the order they are opened. One pick each, so this is also the squad:
|
|
9
|
-
* 4 outsides,
|
|
13
|
+
* The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
|
|
14
|
+
* 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
|
|
15
|
+
* fifteenth free pick on top of it.
|
|
10
16
|
*
|
|
11
17
|
* Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
|
|
12
|
-
* wrong, so nobody drafts six liberos and then cannot field a legal lineup.
|
|
18
|
+
* wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
|
|
19
|
+
* stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
|
|
20
|
+
* slot the squad depends on.
|
|
13
21
|
*/
|
|
14
22
|
export declare const DRAFT_PLAN: readonly DraftRole[];
|
|
15
23
|
/** The size of a drafted squad: one pick per pack. */
|
|
16
24
|
export declare const DRAFT_SQUAD_SIZE: number;
|
|
17
25
|
/**
|
|
18
|
-
* How
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* pack of duds while another opens a pack of stars and the event is decided before a ball is served. A player
|
|
22
|
-
* whose role score falls outside the band is rerolled.
|
|
26
|
+
* How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
|
|
27
|
+
* out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
|
|
28
|
+
* away from it.
|
|
23
29
|
*/
|
|
24
|
-
export declare const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
readonly min: number;
|
|
28
|
-
readonly max: number;
|
|
29
|
-
}
|
|
30
|
+
export declare const TARGETED_PACK_COUNT: number;
|
|
31
|
+
/** How many players the free pack offers: one per position. */
|
|
32
|
+
export declare const FREE_PACK_SIZE: number;
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
35
|
+
*
|
|
36
|
+
* Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
|
|
37
|
+
* fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
|
|
38
|
+
* legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
|
|
39
|
+
* plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
|
|
40
|
+
* its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
|
|
41
|
+
* player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
|
|
42
|
+
* material is comparable in one draw.
|
|
34
43
|
*/
|
|
35
|
-
export declare
|
|
44
|
+
export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
|
|
36
45
|
/**
|
|
37
|
-
* The players a pack offers
|
|
38
|
-
* the
|
|
46
|
+
* The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
|
|
47
|
+
* the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
|
|
39
48
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
49
|
+
* The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
|
|
50
|
+
* the best; legendaries are left uniform.
|
|
42
51
|
*/
|
|
43
|
-
export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole
|
|
52
|
+
export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
|
|
44
53
|
/** The role of the pack at `index`, or undefined once the draft is complete. */
|
|
45
54
|
export declare function draftPackRole(index: number): DraftRole | undefined;
|
|
46
55
|
export declare function isDraftComplete(picks: number): boolean;
|
|
@@ -1,80 +1,92 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.packQualityBand = packQualityBand;
|
|
3
|
+
exports.EVENT_DRAFT_TOP_SKEW = exports.FREE_PACK_SIZE = exports.TARGETED_PACK_COUNT = exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN = exports.PACK_SIZE = exports.FREE_PACK_ROLE = void 0;
|
|
5
4
|
exports.generatePack = generatePack;
|
|
6
5
|
exports.draftPackRole = draftPackRole;
|
|
7
6
|
exports.isDraftComplete = isDraftComplete;
|
|
8
7
|
const player_1 = require("../player");
|
|
9
|
-
|
|
8
|
+
// The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
|
|
9
|
+
// packs, keep one player from each, and the resulting team is thrown away when the event ends.
|
|
10
|
+
//
|
|
11
|
+
// This module is pure: the pack sequence and pack generation. Persistence (which packs a user has opened, what
|
|
12
|
+
// they picked) lives in the API, because the draft is user-driven rather than scheduled.
|
|
13
|
+
/**
|
|
14
|
+
* The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
|
|
15
|
+
* read by the view to label it, so it is a named constant rather than a bare string in three repos.
|
|
16
|
+
*/
|
|
17
|
+
exports.FREE_PACK_ROLE = 'ANY';
|
|
18
|
+
/** How many players a role-targeted pack offers. */
|
|
10
19
|
exports.PACK_SIZE = 4;
|
|
11
20
|
/**
|
|
12
|
-
* The pack sequence, in the order they are opened. One pick each, so this is also the squad:
|
|
13
|
-
* 4 outsides,
|
|
21
|
+
* The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
|
|
22
|
+
* 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
|
|
23
|
+
* fifteenth free pick on top of it.
|
|
14
24
|
*
|
|
15
25
|
* Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
|
|
16
|
-
* wrong, so nobody drafts six liberos and then cannot field a legal lineup.
|
|
26
|
+
* wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
|
|
27
|
+
* stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
|
|
28
|
+
* slot the squad depends on.
|
|
17
29
|
*/
|
|
18
30
|
exports.DRAFT_PLAN = [
|
|
19
31
|
player_1.RoleEnum.SETTER, player_1.RoleEnum.SETTER,
|
|
20
32
|
player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER,
|
|
21
|
-
player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER,
|
|
33
|
+
player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.MIDDLE_BLOCKER,
|
|
22
34
|
player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.OPPOSITE_HITTER,
|
|
23
35
|
player_1.RoleEnum.LIBERO, player_1.RoleEnum.LIBERO,
|
|
24
|
-
|
|
36
|
+
exports.FREE_PACK_ROLE
|
|
25
37
|
];
|
|
26
38
|
/** The size of a drafted squad: one pick per pack. */
|
|
27
39
|
exports.DRAFT_SQUAD_SIZE = exports.DRAFT_PLAN.length;
|
|
28
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
|
|
42
|
+
* out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
|
|
43
|
+
* away from it.
|
|
44
|
+
*/
|
|
45
|
+
exports.TARGETED_PACK_COUNT = exports.DRAFT_PLAN.filter(role => role !== exports.FREE_PACK_ROLE).length;
|
|
46
|
+
/**
|
|
47
|
+
* The free pack offers one player per position, in this order, so the entrant chooses which position to
|
|
48
|
+
* strengthen rather than being handed a position at random.
|
|
49
|
+
*
|
|
50
|
+
* It used to roll ONE role at random and offer four of it, which meant a 1-in-5 lottery decided whether an
|
|
51
|
+
* entrant ended up with a third setter or a fifth outside. The squad is complete without this pick now, so
|
|
52
|
+
* offering the whole board is both fairer and the point of calling it free.
|
|
53
|
+
*/
|
|
29
54
|
const ANY_PACK_ROLES = [
|
|
30
55
|
player_1.RoleEnum.SETTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.LIBERO
|
|
31
56
|
];
|
|
57
|
+
/** How many players the free pack offers: one per position. */
|
|
58
|
+
exports.FREE_PACK_SIZE = ANY_PACK_ROLES.length;
|
|
32
59
|
/**
|
|
33
|
-
* How
|
|
60
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
34
61
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
62
|
+
* Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
|
|
63
|
+
* fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
|
|
64
|
+
* legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
|
|
65
|
+
* plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
|
|
66
|
+
* its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
|
|
67
|
+
* player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
|
|
68
|
+
* material is comparable in one draw.
|
|
38
69
|
*/
|
|
39
|
-
exports.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
function packQualityBand(country, rarity, role, sampleSize = 60) {
|
|
48
|
-
const scores = Array.from({ length: sampleSize }, () => (0, player_1.calculateRoleScore)(player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1).stats, role)).sort((a, b) => a - b);
|
|
49
|
-
const median = scores[Math.floor(scores.length / 2)];
|
|
50
|
-
return {
|
|
51
|
-
role,
|
|
52
|
-
min: median * (1 - exports.PACK_QUALITY_TOLERANCE),
|
|
53
|
-
max: median * (1 + exports.PACK_QUALITY_TOLERANCE)
|
|
54
|
-
};
|
|
55
|
-
}
|
|
70
|
+
exports.EVENT_DRAFT_TOP_SKEW = {
|
|
71
|
+
[player_1.RarityEnum.COMMON]: 2,
|
|
72
|
+
[player_1.RarityEnum.RARE]: 2,
|
|
73
|
+
[player_1.RarityEnum.LEGENDARY]: 0,
|
|
74
|
+
[player_1.RarityEnum.MYTHIC]: 0,
|
|
75
|
+
[player_1.RarityEnum.SPECIAL]: 0
|
|
76
|
+
};
|
|
56
77
|
/**
|
|
57
|
-
* The players a pack offers
|
|
58
|
-
* the
|
|
78
|
+
* The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
|
|
79
|
+
* the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
|
|
59
80
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
81
|
+
* The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
|
|
82
|
+
* the best; legendaries are left uniform.
|
|
62
83
|
*/
|
|
63
|
-
function generatePack(country, rarity, packRole
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
for (let attempt = 0; attempt < MAX_REROLLS; attempt++) {
|
|
70
|
-
const score = (0, player_1.calculateRoleScore)(player.stats, role);
|
|
71
|
-
if (score >= min && score <= max)
|
|
72
|
-
break;
|
|
73
|
-
player = player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1);
|
|
74
|
-
}
|
|
75
|
-
players.push(player);
|
|
76
|
-
}
|
|
77
|
-
return players;
|
|
84
|
+
function generatePack(country, rarity, packRole) {
|
|
85
|
+
const skew = exports.EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
|
|
86
|
+
const roll = (role) => player_1.PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew);
|
|
87
|
+
if (packRole === exports.FREE_PACK_ROLE)
|
|
88
|
+
return ANY_PACK_ROLES.map(roll);
|
|
89
|
+
return Array.from({ length: exports.PACK_SIZE }, () => roll(packRole));
|
|
78
90
|
}
|
|
79
91
|
/** The role of the pack at `index`, or undefined once the draft is complete. */
|
|
80
92
|
function draftPackRole(index) {
|
|
@@ -5,29 +5,55 @@ const event_draft_1 = require("./event-draft");
|
|
|
5
5
|
const player_1 = require("../player");
|
|
6
6
|
const test_helpers_1 = require("../test-helpers");
|
|
7
7
|
const COUNTRY = (0, test_helpers_1.makeCountry)();
|
|
8
|
+
// Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
|
|
9
|
+
// off the flaky edge while still failing loudly if the skew were removed.
|
|
10
|
+
function median(values) {
|
|
11
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
12
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
13
|
+
}
|
|
14
|
+
function percentile(values, p) {
|
|
15
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
16
|
+
return sorted[Math.floor((sorted.length - 1) * p)];
|
|
17
|
+
}
|
|
18
|
+
/** Role scores of `n` recruits drawn the way the DRAFT does (generatePack applies EVENT_DRAFT_TOP_SKEW). */
|
|
19
|
+
function draftScores(rarity, role, n) {
|
|
20
|
+
const out = [];
|
|
21
|
+
while (out.length < n) {
|
|
22
|
+
for (const p of (0, event_draft_1.generatePack)(COUNTRY, rarity, role))
|
|
23
|
+
out.push((0, player_1.calculateRoleScore)(p.stats, role));
|
|
24
|
+
}
|
|
25
|
+
return out.slice(0, n);
|
|
26
|
+
}
|
|
27
|
+
/** Role scores of `n` recruits drawn UNIFORMLY (the generator's historic behaviour, skew 0). */
|
|
28
|
+
function uniformScores(rarity, role, n) {
|
|
29
|
+
return Array.from({ length: n }, () => (0, player_1.calculateRoleScore)(player_1.PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
|
|
30
|
+
}
|
|
8
31
|
(0, globals_1.describe)('DRAFT_PLAN', () => {
|
|
9
|
-
(0, globals_1.it)('drafts a
|
|
10
|
-
(0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(
|
|
11
|
-
(0, globals_1.expect)(event_draft_1.DRAFT_PLAN).toHaveLength(
|
|
32
|
+
(0, globals_1.it)('drafts a 15-player squad, one pick per pack', () => {
|
|
33
|
+
(0, globals_1.expect)(event_draft_1.DRAFT_SQUAD_SIZE).toBe(15);
|
|
34
|
+
(0, globals_1.expect)(event_draft_1.DRAFT_PLAN).toHaveLength(15);
|
|
12
35
|
});
|
|
13
|
-
(0, globals_1.it)('
|
|
36
|
+
(0, globals_1.it)('fills every position outright, then adds the free pick on top', () => {
|
|
14
37
|
const counts = event_draft_1.DRAFT_PLAN.reduce((acc, role) => {
|
|
15
38
|
acc[role] = (acc[role] ?? 0) + 1;
|
|
16
39
|
return acc;
|
|
17
40
|
}, {});
|
|
18
41
|
(0, globals_1.expect)(counts[player_1.RoleEnum.SETTER]).toBe(2);
|
|
19
42
|
(0, globals_1.expect)(counts[player_1.RoleEnum.OUTSIDE_HITTER]).toBe(4);
|
|
20
|
-
(0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBe(
|
|
43
|
+
(0, globals_1.expect)(counts[player_1.RoleEnum.MIDDLE_BLOCKER]).toBe(4);
|
|
21
44
|
(0, globals_1.expect)(counts[player_1.RoleEnum.OPPOSITE_HITTER]).toBe(2);
|
|
22
45
|
(0, globals_1.expect)(counts[player_1.RoleEnum.LIBERO]).toBe(2);
|
|
23
46
|
(0, globals_1.expect)(counts.ANY).toBe(1);
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
(0, globals_1.expect)(
|
|
28
|
-
(0, globals_1.expect)(
|
|
29
|
-
(0, globals_1.expect)(
|
|
30
|
-
|
|
47
|
+
// The targeted packs alone are a complete 14-player squad, so the free pick adds to a squad that is
|
|
48
|
+
// already legal rather than filling a hole in it.
|
|
49
|
+
const targeted = event_draft_1.DRAFT_PLAN.filter(role => role !== event_draft_1.FREE_PACK_ROLE);
|
|
50
|
+
(0, globals_1.expect)(targeted).toHaveLength(14);
|
|
51
|
+
(0, globals_1.expect)(event_draft_1.TARGETED_PACK_COUNT).toBe(14);
|
|
52
|
+
(0, globals_1.expect)(event_draft_1.TARGETED_PACK_COUNT).toBe(event_draft_1.DRAFT_SQUAD_SIZE - 1);
|
|
53
|
+
});
|
|
54
|
+
(0, globals_1.it)('puts the free pick last, so nothing after it depends on what was taken', () => {
|
|
55
|
+
(0, globals_1.expect)(event_draft_1.DRAFT_PLAN[event_draft_1.DRAFT_PLAN.length - 1]).toBe(event_draft_1.FREE_PACK_ROLE);
|
|
56
|
+
(0, globals_1.expect)(event_draft_1.DRAFT_PLAN.filter(role => role === event_draft_1.FREE_PACK_ROLE)).toHaveLength(1);
|
|
31
57
|
});
|
|
32
58
|
(0, globals_1.it)('walks pack by pack and then reports the draft complete', () => {
|
|
33
59
|
for (let i = 0; i < event_draft_1.DRAFT_SQUAD_SIZE; i++) {
|
|
@@ -39,53 +65,70 @@ const COUNTRY = (0, test_helpers_1.makeCountry)();
|
|
|
39
65
|
});
|
|
40
66
|
});
|
|
41
67
|
(0, globals_1.describe)('generatePack()', () => {
|
|
42
|
-
|
|
43
|
-
globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('offers %s players of the pack role, all inside the fairness band', (rarity) => {
|
|
68
|
+
globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('offers %s players of the pack role', (rarity) => {
|
|
44
69
|
const role = player_1.RoleEnum.OUTSIDE_HITTER;
|
|
45
|
-
const
|
|
46
|
-
const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role, () => band, first);
|
|
70
|
+
const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, role);
|
|
47
71
|
(0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
|
|
48
72
|
for (const player of pack) {
|
|
49
73
|
(0, globals_1.expect)(player.rarity).toBe(rarity);
|
|
50
74
|
(0, globals_1.expect)(player.roles).toContain(role);
|
|
51
|
-
const score = (0, player_1.calculateRoleScore)(player.stats, role);
|
|
52
|
-
(0, globals_1.expect)(score).toBeGreaterThanOrEqual(band.min);
|
|
53
|
-
(0, globals_1.expect)(score).toBeLessThanOrEqual(band.max);
|
|
54
75
|
}
|
|
55
76
|
});
|
|
56
|
-
(0, globals_1.it)('
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
(0, globals_1.it)('offers one player per position in the free pack, so every position is on the board', () => {
|
|
78
|
+
const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.COMMON, event_draft_1.FREE_PACK_ROLE);
|
|
79
|
+
(0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
|
|
80
|
+
(0, globals_1.expect)(event_draft_1.FREE_PACK_SIZE).toBe(5);
|
|
81
|
+
// The primary role is the one the pack was rolled at, and all five positions appear exactly once.
|
|
82
|
+
const primary = pack.map(p => p.roles[0]);
|
|
83
|
+
(0, globals_1.expect)([...primary].sort()).toEqual([
|
|
84
|
+
player_1.RoleEnum.LIBERO, player_1.RoleEnum.MIDDLE_BLOCKER, player_1.RoleEnum.OPPOSITE_HITTER, player_1.RoleEnum.OUTSIDE_HITTER, player_1.RoleEnum.SETTER
|
|
85
|
+
].sort());
|
|
65
86
|
});
|
|
66
|
-
(
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
(0, globals_1.expect)(pack).toHaveLength(event_draft_1.PACK_SIZE);
|
|
87
|
+
globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE, player_1.RarityEnum.LEGENDARY])('rolls the free pack at the event rarity (%s), like every other pack', (rarity) => {
|
|
88
|
+
const pack = (0, event_draft_1.generatePack)(COUNTRY, rarity, event_draft_1.FREE_PACK_ROLE);
|
|
89
|
+
(0, globals_1.expect)(pack).toHaveLength(event_draft_1.FREE_PACK_SIZE);
|
|
70
90
|
for (const player of pack)
|
|
71
|
-
(0, globals_1.expect)(player.
|
|
91
|
+
(0, globals_1.expect)(player.rarity).toBe(rarity);
|
|
72
92
|
});
|
|
73
|
-
(0, globals_1.it)('
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
(0, globals_1.it)('never offers the same player twice in a free pack', () => {
|
|
94
|
+
const pack = (0, event_draft_1.generatePack)(COUNTRY, player_1.RarityEnum.RARE, event_draft_1.FREE_PACK_ROLE);
|
|
95
|
+
(0, globals_1.expect)(new Set(pack.map(p => p.id)).size).toBe(event_draft_1.FREE_PACK_SIZE);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
(0, globals_1.describe)('EVENT_DRAFT_TOP_SKEW', () => {
|
|
99
|
+
(0, globals_1.it)('skews commons and rares toward the top and leaves legendaries (and up) uniform', () => {
|
|
100
|
+
(0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.COMMON]).toBeGreaterThan(0);
|
|
101
|
+
(0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.RARE]).toBeGreaterThan(0);
|
|
102
|
+
(0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.LEGENDARY]).toBe(0);
|
|
103
|
+
(0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.MYTHIC]).toBe(0);
|
|
104
|
+
(0, globals_1.expect)(event_draft_1.EVENT_DRAFT_TOP_SKEW[player_1.RarityEnum.SPECIAL]).toBe(0);
|
|
78
105
|
});
|
|
79
106
|
});
|
|
80
|
-
(0, globals_1.describe)('
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
(
|
|
107
|
+
(0, globals_1.describe)('draft skew behaviour', () => {
|
|
108
|
+
const N = 2500;
|
|
109
|
+
const role = player_1.RoleEnum.OUTSIDE_HITTER;
|
|
110
|
+
globals_1.it.each([player_1.RarityEnum.COMMON, player_1.RarityEnum.RARE])('draws %s recruits near the top: the typical recruit outscores a uniform draw, and most land in its top fifth', (rarity) => {
|
|
111
|
+
const uniform = uniformScores(rarity, role, N);
|
|
112
|
+
const drafted = draftScores(rarity, role, N);
|
|
113
|
+
// The 80th percentile of a UNIFORM draw is the entry to the top fifth of the range.
|
|
114
|
+
const topFifthFloor = percentile(uniform, 0.80);
|
|
115
|
+
const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
|
|
116
|
+
// A uniform draw puts ~20% here; the skew must push well past that (measured ~65-88% at skew 2).
|
|
117
|
+
(0, globals_1.expect)(draftedInTopFifth).toBeGreaterThan(0.45);
|
|
118
|
+
// The typical drafted recruit clearly outscores the typical uniform one.
|
|
119
|
+
(0, globals_1.expect)(median(drafted)).toBeGreaterThan(median(uniform));
|
|
120
|
+
// But it is a skew, not a clamp to the maximum: recruits still spread out, and the typical one sits
|
|
121
|
+
// below the best in the sample rather than everyone landing on the ceiling.
|
|
122
|
+
(0, globals_1.expect)(percentile(drafted, 0.10)).toBeLessThan(percentile(drafted, 0.90));
|
|
123
|
+
(0, globals_1.expect)(median(drafted)).toBeLessThan(Math.max(...drafted));
|
|
85
124
|
});
|
|
86
|
-
(0, globals_1.it)('
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
(
|
|
125
|
+
(0, globals_1.it)('leaves legendary recruits uniform: no top-end lift over a plain draw', () => {
|
|
126
|
+
const uniform = uniformScores(player_1.RarityEnum.LEGENDARY, role, N);
|
|
127
|
+
const drafted = draftScores(player_1.RarityEnum.LEGENDARY, role, N);
|
|
128
|
+
const topFifthFloor = percentile(uniform, 0.80);
|
|
129
|
+
const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
|
|
130
|
+
// Same distribution as uniform (skew 0), so it stays near the ~20% a plain draw produces, nowhere near the
|
|
131
|
+
// >45% the skewed lower rarities reach.
|
|
132
|
+
(0, globals_1.expect)(draftedInTopFifth).toBeLessThan(0.35);
|
|
90
133
|
});
|
|
91
134
|
});
|
|
@@ -42,7 +42,7 @@ export declare function rollRarity(pity: PityState, config?: GachaPullConfig): R
|
|
|
42
42
|
export declare class PlayerGenerator {
|
|
43
43
|
private constructor();
|
|
44
44
|
private static generatePerformance;
|
|
45
|
-
static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number): Player;
|
|
45
|
+
static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number, topSkew?: number): Player;
|
|
46
46
|
static generatePlayers(count: number, countries: Country[]): Player[];
|
|
47
47
|
}
|
|
48
48
|
export declare function pickPlayerCountry(teamCountry: Country, allCountries: Country[]): Country;
|
|
@@ -209,7 +209,7 @@ function rollRarity(pity, config = WORLD_PULL_CONFIG) {
|
|
|
209
209
|
/* eslint-disable @typescript-eslint/no-extraneous-class */
|
|
210
210
|
class PlayerGenerator {
|
|
211
211
|
constructor() { }
|
|
212
|
-
static generatePerformance(rarity, role) {
|
|
212
|
+
static generatePerformance(rarity, role, topSkew = 0) {
|
|
213
213
|
const ATTACK = (0, stats_1.getMultipliers)(stats_1.StatsEnum.ATTACK);
|
|
214
214
|
const SET = (0, stats_1.getMultipliers)(stats_1.StatsEnum.SET);
|
|
215
215
|
const RECEIVE = (0, stats_1.getMultipliers)(stats_1.StatsEnum.RECEIVE);
|
|
@@ -220,8 +220,10 @@ class PlayerGenerator {
|
|
|
220
220
|
// Stamina always uses its own per-rarity range (guaranteed floor), regardless of role weight.
|
|
221
221
|
if (key === 'stamina')
|
|
222
222
|
return [key, rollStamina(rarity)];
|
|
223
|
+
// On-role stats drive the role score, so a positive topSkew bends only these toward the top of the
|
|
224
|
+
// range; off-role stats stay uniform. topSkew defaults to 0 (the historic uniform draw).
|
|
223
225
|
if (weights[key] > 0)
|
|
224
|
-
return [key, (
|
|
226
|
+
return [key, randomIntSkewed(minValue, maxValue, topSkew)];
|
|
225
227
|
else
|
|
226
228
|
return [key, (0, node_crypto_1.randomInt)(rarity_1.RarityRanges.COMMON[0], Math.max(minValue - 1, rarity_1.RarityRanges.COMMON[0] + 1))];
|
|
227
229
|
})));
|
|
@@ -315,10 +317,10 @@ class PlayerGenerator {
|
|
|
315
317
|
}
|
|
316
318
|
}
|
|
317
319
|
}
|
|
318
|
-
static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1) {
|
|
320
|
+
static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1, topSkew = 0) {
|
|
319
321
|
const rarity = _rarity ?? rollRarity({ legendaryPity: 0, mythicPity: 0, specialPity: 0 }).rarity;
|
|
320
322
|
const name = (0, utils_1.generatePlayerName)(country.locales, 1)[0];
|
|
321
|
-
const stats = PlayerGenerator.generatePerformance(rarity, role);
|
|
323
|
+
const stats = PlayerGenerator.generatePerformance(rarity, role, topSkew);
|
|
322
324
|
const roles = (0, role_1.assignRoles)(stats, rarity, role);
|
|
323
325
|
const traits = (0, trait_1.assignTraits)(roles, rarity, maxTraits);
|
|
324
326
|
const birthAge = (0, node_crypto_1.randomInt)(15, 21);
|
|
@@ -360,6 +362,21 @@ function rollStamina(rarity) {
|
|
|
360
362
|
const [low, high] = rarity_1.StaminaRanges[rarity];
|
|
361
363
|
return (0, node_crypto_1.randomInt)(low, high + 1);
|
|
362
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* A crypto.randomInt draw over [min, max] inclusive, optionally skewed toward the top.
|
|
367
|
+
*
|
|
368
|
+
* skew = 0 is the plain uniform draw the generator has always used. A positive skew bends a uniform fraction
|
|
369
|
+
* upward with u^(1/(1+skew)), so higher values become progressively likelier while the maximum is never forced,
|
|
370
|
+
* which keeps the result random rather than a maxed stat. Event drafts use this to make the lower rarities'
|
|
371
|
+
* recruits cluster near the top of their range (see EVENT_DRAFT_TOP_SKEW).
|
|
372
|
+
*/
|
|
373
|
+
function randomIntSkewed(min, max, skew) {
|
|
374
|
+
if (skew <= 0 || max <= min)
|
|
375
|
+
return (0, node_crypto_1.randomInt)(min, max + 1);
|
|
376
|
+
const u = (0, node_crypto_1.randomInt)(0, 1000001) / 1000000;
|
|
377
|
+
const frac = Math.pow(u, 1 / (1 + skew));
|
|
378
|
+
return min + Math.round(frac * (max - min));
|
|
379
|
+
}
|
|
363
380
|
function assignValues(stat, low, high, performance) {
|
|
364
381
|
performance_stats_1.performanceStatKeys.filter((key) => stat[key] != null && stat[key] > 0)
|
|
365
382
|
.forEach((key) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Sequelize from 'sequelize';
|
|
2
2
|
import { Model } from 'sequelize';
|
|
3
|
-
import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
|
|
3
|
+
import { CompetitionChampionAttributes, CompetitionChampionId, CompetitionChampionModel, CompetitionMVPAttributes, CompetitionMVPId, CompetitionMVPModel, CompetitionMatchAttributes, CompetitionMatchId, CompetitionMatchModel, CompetitionStandingsAttributes, CompetitionStandingsId, CompetitionStandingsModel, CompetitionTeamsAttributes, CompetitionTeamsId, CompetitionTeamsModel, PromotionMatchAttributes, PromotionMatchId, PromotionMatchModel, RegionQualifierAttributes, RegionQualifierId, RegionQualifierModel, NationalCountryAttributes, NationalCountryId, NationalCountryModel, EventCompetitionAttributes, EventCompetitionId, EventCompetitionModel, DivisionSeasonAttributes, DivisionSeasonId, DivisionSeasonModel, IterationId, IterationModel, TeamId, TeamModel } from '.';
|
|
4
4
|
import { Status } from '../common';
|
|
5
5
|
export interface CompetitionAttributes {
|
|
6
6
|
competition_id: string;
|
|
@@ -15,6 +15,8 @@ export interface CompetitionAttributes {
|
|
|
15
15
|
CompetitionTeams?: CompetitionTeamsAttributes[];
|
|
16
16
|
RegionQualifier?: RegionQualifierAttributes;
|
|
17
17
|
NationalCountry?: NationalCountryAttributes;
|
|
18
|
+
/** Set only on an EVENT competition: which event it belongs to, and which stage of it. */
|
|
19
|
+
EventCompetition?: EventCompetitionAttributes;
|
|
18
20
|
UpperPromotionMatches?: PromotionMatchAttributes[];
|
|
19
21
|
LowerPromotionMatches?: PromotionMatchAttributes[];
|
|
20
22
|
}
|
|
@@ -72,6 +74,10 @@ export declare class CompetitionModel extends Model<CompetitionAttributes, Compe
|
|
|
72
74
|
getNationalCountry: Sequelize.HasOneGetAssociationMixin<NationalCountryModel>;
|
|
73
75
|
setNationalCountry: Sequelize.HasOneSetAssociationMixin<NationalCountryModel, NationalCountryId>;
|
|
74
76
|
createNationalCountry: Sequelize.HasOneCreateAssociationMixin<NationalCountryModel>;
|
|
77
|
+
EventCompetition: EventCompetitionModel;
|
|
78
|
+
getEventCompetition: Sequelize.HasOneGetAssociationMixin<EventCompetitionModel>;
|
|
79
|
+
setEventCompetition: Sequelize.HasOneSetAssociationMixin<EventCompetitionModel, EventCompetitionId>;
|
|
80
|
+
createEventCompetition: Sequelize.HasOneCreateAssociationMixin<EventCompetitionModel>;
|
|
75
81
|
UpperPromotionMatches: PromotionMatchModel[];
|
|
76
82
|
getUpperPromotionMatches: Sequelize.HasManyGetAssociationsMixin<PromotionMatchModel>;
|
|
77
83
|
setUpperPromotionMatches: Sequelize.HasManySetAssociationsMixin<PromotionMatchModel, PromotionMatchId>;
|
|
@@ -1,46 +1,55 @@
|
|
|
1
1
|
import { Country } from '../country';
|
|
2
2
|
import { Player, Rarity, Role } from '../player';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
|
|
5
|
+
* read by the view to label it, so it is a named constant rather than a bare string in three repos.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FREE_PACK_ROLE = "ANY";
|
|
8
|
+
/** A pack targets one role, or the free pick. */
|
|
9
|
+
export type DraftRole = Role | typeof FREE_PACK_ROLE;
|
|
10
|
+
/** How many players a role-targeted pack offers. */
|
|
6
11
|
export declare const PACK_SIZE = 4;
|
|
7
12
|
/**
|
|
8
|
-
* The pack sequence, in the order they are opened. One pick each, so this is also the squad:
|
|
9
|
-
* 4 outsides,
|
|
13
|
+
* The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
|
|
14
|
+
* 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
|
|
15
|
+
* fifteenth free pick on top of it.
|
|
10
16
|
*
|
|
11
17
|
* Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
|
|
12
|
-
* wrong, so nobody drafts six liberos and then cannot field a legal lineup.
|
|
18
|
+
* wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
|
|
19
|
+
* stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
|
|
20
|
+
* slot the squad depends on.
|
|
13
21
|
*/
|
|
14
22
|
export declare const DRAFT_PLAN: readonly DraftRole[];
|
|
15
23
|
/** The size of a drafted squad: one pick per pack. */
|
|
16
24
|
export declare const DRAFT_SQUAD_SIZE: number;
|
|
17
25
|
/**
|
|
18
|
-
* How
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* pack of duds while another opens a pack of stars and the event is decided before a ball is served. A player
|
|
22
|
-
* whose role score falls outside the band is rerolled.
|
|
26
|
+
* How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
|
|
27
|
+
* out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
|
|
28
|
+
* away from it.
|
|
23
29
|
*/
|
|
24
|
-
export declare const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
readonly min: number;
|
|
28
|
-
readonly max: number;
|
|
29
|
-
}
|
|
30
|
+
export declare const TARGETED_PACK_COUNT: number;
|
|
31
|
+
/** How many players the free pack offers: one per position. */
|
|
32
|
+
export declare const FREE_PACK_SIZE: number;
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
35
|
+
*
|
|
36
|
+
* Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
|
|
37
|
+
* fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
|
|
38
|
+
* legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
|
|
39
|
+
* plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
|
|
40
|
+
* its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
|
|
41
|
+
* player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
|
|
42
|
+
* material is comparable in one draw.
|
|
34
43
|
*/
|
|
35
|
-
export declare
|
|
44
|
+
export declare const EVENT_DRAFT_TOP_SKEW: Record<Rarity, number>;
|
|
36
45
|
/**
|
|
37
|
-
* The players a pack offers
|
|
38
|
-
* the
|
|
46
|
+
* The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
|
|
47
|
+
* the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
|
|
39
48
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
49
|
+
* The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
|
|
50
|
+
* the best; legendaries are left uniform.
|
|
42
51
|
*/
|
|
43
|
-
export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole
|
|
52
|
+
export declare function generatePack(country: Country, rarity: Rarity, packRole: DraftRole): Player[];
|
|
44
53
|
/** The role of the pack at `index`, or undefined once the draft is complete. */
|
|
45
54
|
export declare function draftPackRole(index: number): DraftRole | undefined;
|
|
46
55
|
export declare function isDraftComplete(picks: number): boolean;
|
|
@@ -1,73 +1,86 @@
|
|
|
1
|
-
import { PlayerGenerator,
|
|
2
|
-
|
|
1
|
+
import { PlayerGenerator, RarityEnum, RoleEnum } from '../player';
|
|
2
|
+
// The event draft. An entrant does not bring their own squad: they open a fixed sequence of role-targeted
|
|
3
|
+
// packs, keep one player from each, and the resulting team is thrown away when the event ends.
|
|
4
|
+
//
|
|
5
|
+
// This module is pure: the pack sequence and pack generation. Persistence (which packs a user has opened, what
|
|
6
|
+
// they picked) lives in the API, because the draft is user-driven rather than scheduled.
|
|
7
|
+
/**
|
|
8
|
+
* The pack that is not targeted at a position: the single free pick at the end. Stored on the pack row and
|
|
9
|
+
* read by the view to label it, so it is a named constant rather than a bare string in three repos.
|
|
10
|
+
*/
|
|
11
|
+
export const FREE_PACK_ROLE = 'ANY';
|
|
12
|
+
/** How many players a role-targeted pack offers. */
|
|
3
13
|
export const PACK_SIZE = 4;
|
|
4
14
|
/**
|
|
5
|
-
* The pack sequence, in the order they are opened. One pick each, so this is also the squad:
|
|
6
|
-
* 4 outsides,
|
|
15
|
+
* The pack sequence, in the order they are opened. One pick each, so this is also the squad: a full
|
|
16
|
+
* 14-player team by position (2 setters, 4 outsides, 4 middles, 2 opposites, 2 liberos), and then a
|
|
17
|
+
* fifteenth free pick on top of it.
|
|
7
18
|
*
|
|
8
19
|
* Role-targeted rather than free choice on purpose: it makes a positionally valid squad impossible to get
|
|
9
|
-
* wrong, so nobody drafts six liberos and then cannot field a legal lineup.
|
|
20
|
+
* wrong, so nobody drafts six liberos and then cannot field a legal lineup. The fourteen targeted packs
|
|
21
|
+
* stand on their own as a complete squad, which is why the free pick can be a genuine bonus rather than a
|
|
22
|
+
* slot the squad depends on.
|
|
10
23
|
*/
|
|
11
24
|
export const DRAFT_PLAN = [
|
|
12
25
|
RoleEnum.SETTER, RoleEnum.SETTER,
|
|
13
26
|
RoleEnum.OUTSIDE_HITTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.OUTSIDE_HITTER,
|
|
14
|
-
RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER,
|
|
27
|
+
RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.MIDDLE_BLOCKER,
|
|
15
28
|
RoleEnum.OPPOSITE_HITTER, RoleEnum.OPPOSITE_HITTER,
|
|
16
29
|
RoleEnum.LIBERO, RoleEnum.LIBERO,
|
|
17
|
-
|
|
30
|
+
FREE_PACK_ROLE
|
|
18
31
|
];
|
|
19
32
|
/** The size of a drafted squad: one pick per pack. */
|
|
20
33
|
export const DRAFT_SQUAD_SIZE = DRAFT_PLAN.length;
|
|
21
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* How many packs are targeted at a position, which is every pack but the free one. Derived rather than typed
|
|
36
|
+
* out because the copy describing the draft quotes it, and prose that repeats a constant is prose that drifts
|
|
37
|
+
* away from it.
|
|
38
|
+
*/
|
|
39
|
+
export const TARGETED_PACK_COUNT = DRAFT_PLAN.filter(role => role !== FREE_PACK_ROLE).length;
|
|
40
|
+
/**
|
|
41
|
+
* The free pack offers one player per position, in this order, so the entrant chooses which position to
|
|
42
|
+
* strengthen rather than being handed a position at random.
|
|
43
|
+
*
|
|
44
|
+
* It used to roll ONE role at random and offer four of it, which meant a 1-in-5 lottery decided whether an
|
|
45
|
+
* entrant ended up with a third setter or a fifth outside. The squad is complete without this pick now, so
|
|
46
|
+
* offering the whole board is both fairer and the point of calling it free.
|
|
47
|
+
*/
|
|
22
48
|
const ANY_PACK_ROLES = [
|
|
23
49
|
RoleEnum.SETTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.MIDDLE_BLOCKER, RoleEnum.OPPOSITE_HITTER, RoleEnum.LIBERO
|
|
24
50
|
];
|
|
51
|
+
/** How many players the free pack offers: one per position. */
|
|
52
|
+
export const FREE_PACK_SIZE = ANY_PACK_ROLES.length;
|
|
25
53
|
/**
|
|
26
|
-
* How
|
|
54
|
+
* How hard each rarity's recruits are skewed toward the top of their range when drafted in an event.
|
|
27
55
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
56
|
+
* Owner decision (2026-07-23): commons and rares are generated NEAR THEIR BEST, so a lower-rarity event still
|
|
57
|
+
* fields strong squads, while legendaries (and up) stay uniform, so a legendary event fields average
|
|
58
|
+
* legendaries rather than peak ones. The number is a generator skew strength (see randomIntSkewed): 0 is the
|
|
59
|
+
* plain uniform draw, and 2 was measured to land the typical common/rare recruit around the 80th percentile of
|
|
60
|
+
* its normal role-score distribution (the top fifth) while keeping real spread and never forcing a maxed
|
|
61
|
+
* player. This replaces the old reject-and-reroll fairness band: the skew lives in the generator, so the
|
|
62
|
+
* material is comparable in one draw.
|
|
31
63
|
*/
|
|
32
|
-
export const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*/
|
|
40
|
-
export function packQualityBand(country, rarity, role, sampleSize = 60) {
|
|
41
|
-
const scores = Array.from({ length: sampleSize }, () => calculateRoleScore(PlayerGenerator.generatePlayer(country, rarity, role, false, 1).stats, role)).sort((a, b) => a - b);
|
|
42
|
-
const median = scores[Math.floor(scores.length / 2)];
|
|
43
|
-
return {
|
|
44
|
-
role,
|
|
45
|
-
min: median * (1 - PACK_QUALITY_TOLERANCE),
|
|
46
|
-
max: median * (1 + PACK_QUALITY_TOLERANCE)
|
|
47
|
-
};
|
|
48
|
-
}
|
|
64
|
+
export const EVENT_DRAFT_TOP_SKEW = {
|
|
65
|
+
[RarityEnum.COMMON]: 2,
|
|
66
|
+
[RarityEnum.RARE]: 2,
|
|
67
|
+
[RarityEnum.LEGENDARY]: 0,
|
|
68
|
+
[RarityEnum.MYTHIC]: 0,
|
|
69
|
+
[RarityEnum.SPECIAL]: 0
|
|
70
|
+
};
|
|
49
71
|
/**
|
|
50
|
-
* The players a pack offers
|
|
51
|
-
* the
|
|
72
|
+
* The players a pack offers, at the event's rarity. A role-targeted pack offers `PACK_SIZE` of that one role;
|
|
73
|
+
* the free pack offers `FREE_PACK_SIZE`, one per position, so the choice is which position to add to.
|
|
52
74
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
75
|
+
* The lower rarities are generated with a top-end skew (EVENT_DRAFT_TOP_SKEW) so their recruits cluster near
|
|
76
|
+
* the best; legendaries are left uniform.
|
|
55
77
|
*/
|
|
56
|
-
export function generatePack(country, rarity, packRole
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for (let attempt = 0; attempt < MAX_REROLLS; attempt++) {
|
|
63
|
-
const score = calculateRoleScore(player.stats, role);
|
|
64
|
-
if (score >= min && score <= max)
|
|
65
|
-
break;
|
|
66
|
-
player = PlayerGenerator.generatePlayer(country, rarity, role, false, 1);
|
|
67
|
-
}
|
|
68
|
-
players.push(player);
|
|
69
|
-
}
|
|
70
|
-
return players;
|
|
78
|
+
export function generatePack(country, rarity, packRole) {
|
|
79
|
+
const skew = EVENT_DRAFT_TOP_SKEW[rarity] ?? 0;
|
|
80
|
+
const roll = (role) => PlayerGenerator.generatePlayer(country, rarity, role, false, 1, skew);
|
|
81
|
+
if (packRole === FREE_PACK_ROLE)
|
|
82
|
+
return ANY_PACK_ROLES.map(roll);
|
|
83
|
+
return Array.from({ length: PACK_SIZE }, () => roll(packRole));
|
|
71
84
|
}
|
|
72
85
|
/** The role of the pack at `index`, or undefined once the draft is complete. */
|
|
73
86
|
export function draftPackRole(index) {
|
|
@@ -1,31 +1,57 @@
|
|
|
1
1
|
import { describe, it, expect } from '@jest/globals';
|
|
2
|
-
import { DRAFT_PLAN, DRAFT_SQUAD_SIZE, PACK_SIZE, draftPackRole, generatePack, isDraftComplete
|
|
3
|
-
import { RarityEnum, RoleEnum, calculateRoleScore } from '../player';
|
|
2
|
+
import { DRAFT_PLAN, DRAFT_SQUAD_SIZE, EVENT_DRAFT_TOP_SKEW, FREE_PACK_ROLE, FREE_PACK_SIZE, PACK_SIZE, TARGETED_PACK_COUNT, draftPackRole, generatePack, isDraftComplete } from './event-draft';
|
|
3
|
+
import { PlayerGenerator, RarityEnum, RoleEnum, calculateRoleScore } from '../player';
|
|
4
4
|
import { makeCountry } from '../test-helpers';
|
|
5
5
|
const COUNTRY = makeCountry();
|
|
6
|
+
// Aggregate helpers: skew is statistical, so the behaviour tests use large samples and generous margins to stay
|
|
7
|
+
// off the flaky edge while still failing loudly if the skew were removed.
|
|
8
|
+
function median(values) {
|
|
9
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
10
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
11
|
+
}
|
|
12
|
+
function percentile(values, p) {
|
|
13
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
14
|
+
return sorted[Math.floor((sorted.length - 1) * p)];
|
|
15
|
+
}
|
|
16
|
+
/** Role scores of `n` recruits drawn the way the DRAFT does (generatePack applies EVENT_DRAFT_TOP_SKEW). */
|
|
17
|
+
function draftScores(rarity, role, n) {
|
|
18
|
+
const out = [];
|
|
19
|
+
while (out.length < n) {
|
|
20
|
+
for (const p of generatePack(COUNTRY, rarity, role))
|
|
21
|
+
out.push(calculateRoleScore(p.stats, role));
|
|
22
|
+
}
|
|
23
|
+
return out.slice(0, n);
|
|
24
|
+
}
|
|
25
|
+
/** Role scores of `n` recruits drawn UNIFORMLY (the generator's historic behaviour, skew 0). */
|
|
26
|
+
function uniformScores(rarity, role, n) {
|
|
27
|
+
return Array.from({ length: n }, () => calculateRoleScore(PlayerGenerator.generatePlayer(COUNTRY, rarity, role, false, 1, 0).stats, role));
|
|
28
|
+
}
|
|
6
29
|
describe('DRAFT_PLAN', () => {
|
|
7
|
-
it('drafts a
|
|
8
|
-
expect(DRAFT_SQUAD_SIZE).toBe(
|
|
9
|
-
expect(DRAFT_PLAN).toHaveLength(
|
|
30
|
+
it('drafts a 15-player squad, one pick per pack', () => {
|
|
31
|
+
expect(DRAFT_SQUAD_SIZE).toBe(15);
|
|
32
|
+
expect(DRAFT_PLAN).toHaveLength(15);
|
|
10
33
|
});
|
|
11
|
-
it('
|
|
34
|
+
it('fills every position outright, then adds the free pick on top', () => {
|
|
12
35
|
const counts = DRAFT_PLAN.reduce((acc, role) => {
|
|
13
36
|
acc[role] = (acc[role] ?? 0) + 1;
|
|
14
37
|
return acc;
|
|
15
38
|
}, {});
|
|
16
39
|
expect(counts[RoleEnum.SETTER]).toBe(2);
|
|
17
40
|
expect(counts[RoleEnum.OUTSIDE_HITTER]).toBe(4);
|
|
18
|
-
expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBe(
|
|
41
|
+
expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBe(4);
|
|
19
42
|
expect(counts[RoleEnum.OPPOSITE_HITTER]).toBe(2);
|
|
20
43
|
expect(counts[RoleEnum.LIBERO]).toBe(2);
|
|
21
44
|
expect(counts.ANY).toBe(1);
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
|
|
45
|
+
// The targeted packs alone are a complete 14-player squad, so the free pick adds to a squad that is
|
|
46
|
+
// already legal rather than filling a hole in it.
|
|
47
|
+
const targeted = DRAFT_PLAN.filter(role => role !== FREE_PACK_ROLE);
|
|
48
|
+
expect(targeted).toHaveLength(14);
|
|
49
|
+
expect(TARGETED_PACK_COUNT).toBe(14);
|
|
50
|
+
expect(TARGETED_PACK_COUNT).toBe(DRAFT_SQUAD_SIZE - 1);
|
|
51
|
+
});
|
|
52
|
+
it('puts the free pick last, so nothing after it depends on what was taken', () => {
|
|
53
|
+
expect(DRAFT_PLAN[DRAFT_PLAN.length - 1]).toBe(FREE_PACK_ROLE);
|
|
54
|
+
expect(DRAFT_PLAN.filter(role => role === FREE_PACK_ROLE)).toHaveLength(1);
|
|
29
55
|
});
|
|
30
56
|
it('walks pack by pack and then reports the draft complete', () => {
|
|
31
57
|
for (let i = 0; i < DRAFT_SQUAD_SIZE; i++) {
|
|
@@ -37,53 +63,70 @@ describe('DRAFT_PLAN', () => {
|
|
|
37
63
|
});
|
|
38
64
|
});
|
|
39
65
|
describe('generatePack()', () => {
|
|
40
|
-
|
|
41
|
-
it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('offers %s players of the pack role, all inside the fairness band', (rarity) => {
|
|
66
|
+
it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('offers %s players of the pack role', (rarity) => {
|
|
42
67
|
const role = RoleEnum.OUTSIDE_HITTER;
|
|
43
|
-
const
|
|
44
|
-
const pack = generatePack(COUNTRY, rarity, role, () => band, first);
|
|
68
|
+
const pack = generatePack(COUNTRY, rarity, role);
|
|
45
69
|
expect(pack).toHaveLength(PACK_SIZE);
|
|
46
70
|
for (const player of pack) {
|
|
47
71
|
expect(player.rarity).toBe(rarity);
|
|
48
72
|
expect(player.roles).toContain(role);
|
|
49
|
-
const score = calculateRoleScore(player.stats, role);
|
|
50
|
-
expect(score).toBeGreaterThanOrEqual(band.min);
|
|
51
|
-
expect(score).toBeLessThanOrEqual(band.max);
|
|
52
73
|
}
|
|
53
74
|
});
|
|
54
|
-
it('
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
it('offers one player per position in the free pack, so every position is on the board', () => {
|
|
76
|
+
const pack = generatePack(COUNTRY, RarityEnum.COMMON, FREE_PACK_ROLE);
|
|
77
|
+
expect(pack).toHaveLength(FREE_PACK_SIZE);
|
|
78
|
+
expect(FREE_PACK_SIZE).toBe(5);
|
|
79
|
+
// The primary role is the one the pack was rolled at, and all five positions appear exactly once.
|
|
80
|
+
const primary = pack.map(p => p.roles[0]);
|
|
81
|
+
expect([...primary].sort()).toEqual([
|
|
82
|
+
RoleEnum.LIBERO, RoleEnum.MIDDLE_BLOCKER, RoleEnum.OPPOSITE_HITTER, RoleEnum.OUTSIDE_HITTER, RoleEnum.SETTER
|
|
83
|
+
].sort());
|
|
63
84
|
});
|
|
64
|
-
it('rolls
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
expect(pack).toHaveLength(PACK_SIZE);
|
|
85
|
+
it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('rolls the free pack at the event rarity (%s), like every other pack', (rarity) => {
|
|
86
|
+
const pack = generatePack(COUNTRY, rarity, FREE_PACK_ROLE);
|
|
87
|
+
expect(pack).toHaveLength(FREE_PACK_SIZE);
|
|
68
88
|
for (const player of pack)
|
|
69
|
-
expect(player.
|
|
89
|
+
expect(player.rarity).toBe(rarity);
|
|
70
90
|
});
|
|
71
|
-
it('
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
it('never offers the same player twice in a free pack', () => {
|
|
92
|
+
const pack = generatePack(COUNTRY, RarityEnum.RARE, FREE_PACK_ROLE);
|
|
93
|
+
expect(new Set(pack.map(p => p.id)).size).toBe(FREE_PACK_SIZE);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('EVENT_DRAFT_TOP_SKEW', () => {
|
|
97
|
+
it('skews commons and rares toward the top and leaves legendaries (and up) uniform', () => {
|
|
98
|
+
expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.COMMON]).toBeGreaterThan(0);
|
|
99
|
+
expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.RARE]).toBeGreaterThan(0);
|
|
100
|
+
expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.LEGENDARY]).toBe(0);
|
|
101
|
+
expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.MYTHIC]).toBe(0);
|
|
102
|
+
expect(EVENT_DRAFT_TOP_SKEW[RarityEnum.SPECIAL]).toBe(0);
|
|
76
103
|
});
|
|
77
104
|
});
|
|
78
|
-
describe('
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
describe('draft skew behaviour', () => {
|
|
106
|
+
const N = 2500;
|
|
107
|
+
const role = RoleEnum.OUTSIDE_HITTER;
|
|
108
|
+
it.each([RarityEnum.COMMON, RarityEnum.RARE])('draws %s recruits near the top: the typical recruit outscores a uniform draw, and most land in its top fifth', (rarity) => {
|
|
109
|
+
const uniform = uniformScores(rarity, role, N);
|
|
110
|
+
const drafted = draftScores(rarity, role, N);
|
|
111
|
+
// The 80th percentile of a UNIFORM draw is the entry to the top fifth of the range.
|
|
112
|
+
const topFifthFloor = percentile(uniform, 0.80);
|
|
113
|
+
const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
|
|
114
|
+
// A uniform draw puts ~20% here; the skew must push well past that (measured ~65-88% at skew 2).
|
|
115
|
+
expect(draftedInTopFifth).toBeGreaterThan(0.45);
|
|
116
|
+
// The typical drafted recruit clearly outscores the typical uniform one.
|
|
117
|
+
expect(median(drafted)).toBeGreaterThan(median(uniform));
|
|
118
|
+
// But it is a skew, not a clamp to the maximum: recruits still spread out, and the typical one sits
|
|
119
|
+
// below the best in the sample rather than everyone landing on the ceiling.
|
|
120
|
+
expect(percentile(drafted, 0.10)).toBeLessThan(percentile(drafted, 0.90));
|
|
121
|
+
expect(median(drafted)).toBeLessThan(Math.max(...drafted));
|
|
83
122
|
});
|
|
84
|
-
it('
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
123
|
+
it('leaves legendary recruits uniform: no top-end lift over a plain draw', () => {
|
|
124
|
+
const uniform = uniformScores(RarityEnum.LEGENDARY, role, N);
|
|
125
|
+
const drafted = draftScores(RarityEnum.LEGENDARY, role, N);
|
|
126
|
+
const topFifthFloor = percentile(uniform, 0.80);
|
|
127
|
+
const draftedInTopFifth = drafted.filter(s => s >= topFifthFloor).length / drafted.length;
|
|
128
|
+
// Same distribution as uniform (skew 0), so it stays near the ~20% a plain draw produces, nowhere near the
|
|
129
|
+
// >45% the skewed lower rarities reach.
|
|
130
|
+
expect(draftedInTopFifth).toBeLessThan(0.35);
|
|
88
131
|
});
|
|
89
132
|
});
|
|
@@ -42,7 +42,7 @@ export declare function rollRarity(pity: PityState, config?: GachaPullConfig): R
|
|
|
42
42
|
export declare class PlayerGenerator {
|
|
43
43
|
private constructor();
|
|
44
44
|
private static generatePerformance;
|
|
45
|
-
static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number): Player;
|
|
45
|
+
static generatePlayer(country: Country, _rarity?: Rarity, role?: Role, maxTraits?: boolean, birthIteration?: number, topSkew?: number): Player;
|
|
46
46
|
static generatePlayers(count: number, countries: Country[]): Player[];
|
|
47
47
|
}
|
|
48
48
|
export declare function pickPlayerCountry(teamCountry: Country, allCountries: Country[]): Country;
|
|
@@ -203,7 +203,7 @@ export function rollRarity(pity, config = WORLD_PULL_CONFIG) {
|
|
|
203
203
|
/* eslint-disable @typescript-eslint/no-extraneous-class */
|
|
204
204
|
export class PlayerGenerator {
|
|
205
205
|
constructor() { }
|
|
206
|
-
static generatePerformance(rarity, role) {
|
|
206
|
+
static generatePerformance(rarity, role, topSkew = 0) {
|
|
207
207
|
const ATTACK = getMultipliers(StatsEnum.ATTACK);
|
|
208
208
|
const SET = getMultipliers(StatsEnum.SET);
|
|
209
209
|
const RECEIVE = getMultipliers(StatsEnum.RECEIVE);
|
|
@@ -214,8 +214,10 @@ export class PlayerGenerator {
|
|
|
214
214
|
// Stamina always uses its own per-rarity range (guaranteed floor), regardless of role weight.
|
|
215
215
|
if (key === 'stamina')
|
|
216
216
|
return [key, rollStamina(rarity)];
|
|
217
|
+
// On-role stats drive the role score, so a positive topSkew bends only these toward the top of the
|
|
218
|
+
// range; off-role stats stay uniform. topSkew defaults to 0 (the historic uniform draw).
|
|
217
219
|
if (weights[key] > 0)
|
|
218
|
-
return [key,
|
|
220
|
+
return [key, randomIntSkewed(minValue, maxValue, topSkew)];
|
|
219
221
|
else
|
|
220
222
|
return [key, randomInt(RarityRanges.COMMON[0], Math.max(minValue - 1, RarityRanges.COMMON[0] + 1))];
|
|
221
223
|
})));
|
|
@@ -309,10 +311,10 @@ export class PlayerGenerator {
|
|
|
309
311
|
}
|
|
310
312
|
}
|
|
311
313
|
}
|
|
312
|
-
static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1) {
|
|
314
|
+
static generatePlayer(country, _rarity, role, maxTraits, birthIteration = 1, topSkew = 0) {
|
|
313
315
|
const rarity = _rarity ?? rollRarity({ legendaryPity: 0, mythicPity: 0, specialPity: 0 }).rarity;
|
|
314
316
|
const name = generatePlayerName(country.locales, 1)[0];
|
|
315
|
-
const stats = PlayerGenerator.generatePerformance(rarity, role);
|
|
317
|
+
const stats = PlayerGenerator.generatePerformance(rarity, role, topSkew);
|
|
316
318
|
const roles = assignRoles(stats, rarity, role);
|
|
317
319
|
const traits = assignTraits(roles, rarity, maxTraits);
|
|
318
320
|
const birthAge = randomInt(15, 21);
|
|
@@ -353,6 +355,21 @@ function rollStamina(rarity) {
|
|
|
353
355
|
const [low, high] = StaminaRanges[rarity];
|
|
354
356
|
return randomInt(low, high + 1);
|
|
355
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* A crypto.randomInt draw over [min, max] inclusive, optionally skewed toward the top.
|
|
360
|
+
*
|
|
361
|
+
* skew = 0 is the plain uniform draw the generator has always used. A positive skew bends a uniform fraction
|
|
362
|
+
* upward with u^(1/(1+skew)), so higher values become progressively likelier while the maximum is never forced,
|
|
363
|
+
* which keeps the result random rather than a maxed stat. Event drafts use this to make the lower rarities'
|
|
364
|
+
* recruits cluster near the top of their range (see EVENT_DRAFT_TOP_SKEW).
|
|
365
|
+
*/
|
|
366
|
+
function randomIntSkewed(min, max, skew) {
|
|
367
|
+
if (skew <= 0 || max <= min)
|
|
368
|
+
return randomInt(min, max + 1);
|
|
369
|
+
const u = randomInt(0, 1000001) / 1000000;
|
|
370
|
+
const frac = Math.pow(u, 1 / (1 + skew));
|
|
371
|
+
return min + Math.round(frac * (max - min));
|
|
372
|
+
}
|
|
356
373
|
function assignValues(stat, low, high, performance) {
|
|
357
374
|
performanceStatKeys.filter((key) => stat[key] != null && stat[key] > 0)
|
|
358
375
|
.forEach((key) => {
|