volleyballsimtypes 0.0.521 → 0.0.523

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.
Files changed (69) hide show
  1. package/dist/cjs/src/api/index.d.ts +1 -1
  2. package/dist/cjs/src/data/country-locales.json +12 -0
  3. package/dist/cjs/src/data/init-models.js +4 -0
  4. package/dist/cjs/src/data/models/currency-transaction.d.ts +3 -2
  5. package/dist/cjs/src/data/models/currency-transaction.js +1 -0
  6. package/dist/cjs/src/data/models/index.d.ts +1 -0
  7. package/dist/cjs/src/data/models/index.js +1 -0
  8. package/dist/cjs/src/data/models/user-achievements.d.ts +19 -0
  9. package/dist/cjs/src/data/models/user-achievements.js +37 -0
  10. package/dist/cjs/src/service/achievement/achievement-status.d.ts +5 -0
  11. package/dist/cjs/src/service/achievement/achievement-status.js +13 -0
  12. package/dist/cjs/src/service/achievement/achievement-type.d.ts +24 -0
  13. package/dist/cjs/src/service/achievement/achievement-type.js +85 -0
  14. package/dist/cjs/src/service/achievement/index.d.ts +3 -0
  15. package/dist/cjs/src/service/achievement/index.js +19 -0
  16. package/dist/cjs/src/service/achievement/schemas/achievements.z.d.ts +3 -0
  17. package/dist/cjs/src/service/achievement/schemas/achievements.z.js +9 -0
  18. package/dist/cjs/src/service/index.d.ts +1 -0
  19. package/dist/cjs/src/service/index.js +1 -0
  20. package/dist/cjs/src/service/match/vper.js +23 -3
  21. package/dist/cjs/src/service/match/vper.test.js +86 -0
  22. package/dist/cjs/src/service/notification/catalog/en.json +4 -0
  23. package/dist/cjs/src/service/notification/catalog/es.json +4 -0
  24. package/dist/cjs/src/service/notification/catalog/fr.json +4 -0
  25. package/dist/cjs/src/service/notification/catalog/it.json +4 -0
  26. package/dist/cjs/src/service/notification/catalog/pl.json +4 -0
  27. package/dist/cjs/src/service/notification/catalog/pt-BR.json +4 -0
  28. package/dist/cjs/src/service/notification/catalog/tr.json +4 -0
  29. package/dist/cjs/src/service/notification/notification-type.d.ts +1 -1
  30. package/dist/cjs/src/service/notification/notification-type.js +2 -1
  31. package/dist/cjs/src/service/notification/registry.js +9 -0
  32. package/dist/cjs/src/service/team/base-config.js +8 -4
  33. package/dist/cjs/src/service/team/default-base-config.js +11 -9
  34. package/dist/cjs/src/service/team/default-base-config.test.js +52 -0
  35. package/dist/esm/src/api/index.d.ts +1 -1
  36. package/dist/esm/src/data/country-locales.json +12 -0
  37. package/dist/esm/src/data/init-models.js +5 -1
  38. package/dist/esm/src/data/models/currency-transaction.d.ts +3 -2
  39. package/dist/esm/src/data/models/currency-transaction.js +1 -0
  40. package/dist/esm/src/data/models/index.d.ts +1 -0
  41. package/dist/esm/src/data/models/index.js +1 -0
  42. package/dist/esm/src/data/models/user-achievements.d.ts +19 -0
  43. package/dist/esm/src/data/models/user-achievements.js +33 -0
  44. package/dist/esm/src/service/achievement/achievement-status.d.ts +5 -0
  45. package/dist/esm/src/service/achievement/achievement-status.js +10 -0
  46. package/dist/esm/src/service/achievement/achievement-type.d.ts +24 -0
  47. package/dist/esm/src/service/achievement/achievement-type.js +77 -0
  48. package/dist/esm/src/service/achievement/index.d.ts +3 -0
  49. package/dist/esm/src/service/achievement/index.js +3 -0
  50. package/dist/esm/src/service/achievement/schemas/achievements.z.d.ts +3 -0
  51. package/dist/esm/src/service/achievement/schemas/achievements.z.js +6 -0
  52. package/dist/esm/src/service/index.d.ts +1 -0
  53. package/dist/esm/src/service/index.js +1 -0
  54. package/dist/esm/src/service/match/vper.js +23 -3
  55. package/dist/esm/src/service/match/vper.test.js +86 -0
  56. package/dist/esm/src/service/notification/catalog/en.json +4 -0
  57. package/dist/esm/src/service/notification/catalog/es.json +4 -0
  58. package/dist/esm/src/service/notification/catalog/fr.json +4 -0
  59. package/dist/esm/src/service/notification/catalog/it.json +4 -0
  60. package/dist/esm/src/service/notification/catalog/pl.json +4 -0
  61. package/dist/esm/src/service/notification/catalog/pt-BR.json +4 -0
  62. package/dist/esm/src/service/notification/catalog/tr.json +4 -0
  63. package/dist/esm/src/service/notification/notification-type.d.ts +1 -1
  64. package/dist/esm/src/service/notification/notification-type.js +2 -1
  65. package/dist/esm/src/service/notification/registry.js +9 -0
  66. package/dist/esm/src/service/team/base-config.js +8 -4
  67. package/dist/esm/src/service/team/default-base-config.js +11 -9
  68. package/dist/esm/src/service/team/default-base-config.test.js +53 -1
  69. package/package.json +1 -1
@@ -199,22 +199,24 @@ const DEFAULT_5_1_RECEIVE = {
199
199
  5: { 1: true, 2: false, 3: true, 4: false, 5: false, 6: true },
200
200
  6: { 1: true, 2: false, 3: false, 4: true, 5: true, 6: false }
201
201
  };
202
- // Rotation index -> the setter's rotational zone (inverse of SETTER_ZONE_TO_ROTATION {1:1,6:2,5:3,4:4,3:5,2:6}).
203
- const ROTATION_TO_SETTER_ZONE = { 1: 1, 2: 6, 3: 5, 4: 4, 5: 3, 6: 2 };
204
- // The default serve-receive participation map: the standard 3-passer formation. A rotational slot passes iff its
205
- // slot function is an OUTSIDE_HITTER (either row) or a BACK-ROW MIDDLE_BLOCKER (the libero's slot). The setter, the
206
- // opposite and the front-row middle hide. slotFunctionAt maps the opposite slot to SETTER in 4-2/6-2, so the
207
- // second setter is excluded too, giving the same 3-passer map for every system.
202
+ // The default serve-receive participation map: the standard 3-passer formation. Keyed by the MAIN setter's rotational
203
+ // ZONE (raw 1..6 = the canonical convention: receive[Z] describes the formation with the setter at zone Z), matching
204
+ // DEFAULT_5_1_RECEIVE, the PRESET_6_2/PRESET_4_2 presets, the tactics editor, and the engine's rotationOf. A rotational
205
+ // slot passes iff its slot function is an OUTSIDE_HITTER (either row) or a BACK-ROW MIDDLE_BLOCKER (the libero's slot);
206
+ // the setter, the opposite and the front-row middle hide. slotFunctionAt maps the opposite slot to SETTER in 4-2/6-2,
207
+ // so the second setter is excluded too, giving the same 3-passer map for every system.
208
+ // (History: this used to key by a rotation INDEX via ROTATION_TO_SETTER_ZONE {1:1,2:6,3:5,4:4,5:3,6:2}, a leftover of
209
+ // the old setter-zone->rotation remap removed from rotationOf; that transposed the non-5-1 default receive maps at
210
+ // rotations 2/3/5/6. Fixed 2026-08-23, see VolleyballSim/plans/rotation-key-main-setter.md + the re-key migration.)
208
211
  function defaultReceiveMap(system) {
209
212
  const receive = {};
210
- for (let rotation = 1; rotation <= 6; rotation++) {
211
- const setterZone = ROTATION_TO_SETTER_ZONE[rotation];
213
+ for (let setterZone = 1; setterZone <= 6; setterZone++) {
212
214
  const perZone = {};
213
215
  for (let zone = 1; zone <= 6; zone++) {
214
216
  const fn = (0, lineup_function_1.slotFunctionAt)(system, setterZone, zone);
215
217
  perZone[String(zone)] = fn === player_1.RoleEnum.OUTSIDE_HITTER || (fn === player_1.RoleEnum.MIDDLE_BLOCKER && (0, match_1.isBackRow)(zone));
216
218
  }
217
- receive[String(rotation)] = perZone;
219
+ receive[String(setterZone)] = perZone;
218
220
  }
219
221
  return receive;
220
222
  }
@@ -2,8 +2,60 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const rotation_system_1 = require("./rotation-system");
4
4
  const default_base_config_1 = require("./default-base-config");
5
+ const receive_overlap_1 = require("./receive-overlap");
5
6
  // Back-row rotational zones (right/left/middle back). Front row is {2,3,4}.
6
7
  const BACK_ROW = new Set([1, 5, 6]);
8
+ // The editor and the sim BOTH fall back to defaultBaseConfig for a zone a config leaves unset, so the default base
9
+ // MUST itself pass the FIVB overlap / serve-line rules in every rotation. Otherwise a partial config (some zones
10
+ // stored, others defaulted) can render/commit an unset zone onto a neighbour and read "out of rotation even in
11
+ // base" for a formation the sim runs legally (rush6@ tournament report, 2026-08-21).
12
+ // The default receive map must key by the MAIN setter's RAW rotational zone (receive[R] describes the formation with
13
+ // the setter at zone R), the single canonical convention shared by DEFAULT_5_1_RECEIVE, the PRESET_6_2/4_2 presets,
14
+ // the tactics editor, and the engine's rotationOf. It used to key by a rotation INDEX (ROTATION_TO_SETTER_ZONE), which
15
+ // transposed non-5-1 maps at rotations 2/3/5/6 (fixed 2026-08-23; see plans/rotation-key-main-setter.md).
16
+ describe('defaultReceiveMap: raw main-setter-zone convention', () => {
17
+ it('5-1: the computed map equals the hand-authored raw DEFAULT_5_1_RECEIVE', () => {
18
+ // defaultBaseConfig(5-1).receive IS the hand-authored DEFAULT_5_1_RECEIVE. The computed map matching it proves
19
+ // defaultReceiveMap keys by the raw setter zone (receive[R] = setter@R), not the old index.
20
+ expect((0, default_base_config_1.defaultReceiveMap)(rotation_system_1.RotationSystemEnum.FIVE_ONE)).toEqual((0, default_base_config_1.defaultBaseConfig)(rotation_system_1.RotationSystemEnum.FIVE_ONE).receive);
21
+ });
22
+ it('every system: receive[R] hides the setter at zone R (raw), never at ROTATION_TO_SETTER_ZONE[R]', () => {
23
+ for (const system of Object.values(rotation_system_1.RotationSystemEnum)) {
24
+ if (system === rotation_system_1.RotationSystemEnum.SIX_ZERO)
25
+ continue; // 6-0 has no designated setter
26
+ const map = (0, default_base_config_1.defaultReceiveMap)(system);
27
+ for (const z of [2, 3, 5, 6]) { // the rotations where raw and the old index convention diverge
28
+ expect({ system, z, passes: map[String(z)]?.[String(z)] }).toEqual({ system, z, passes: false });
29
+ }
30
+ }
31
+ });
32
+ });
33
+ describe('defaultBaseConfig base-config legality', () => {
34
+ it('has zero SERVE/RECEIVE overlap violations for every rotation system', () => {
35
+ for (const system of Object.values(rotation_system_1.RotationSystemEnum)) {
36
+ expect({ system, violations: (0, receive_overlap_1.baseConfigViolations)((0, default_base_config_1.defaultBaseConfig)(system)) }).toEqual({ system, violations: [] });
37
+ }
38
+ });
39
+ });
40
+ // Regression (rush6@ tournament report, 2026-08-21): a partial RECEIVE config (some zones stored, one left unset)
41
+ // reads "out of rotation even in base" ONLY when the editor fills the unset zone from a coarse flat grid (all
42
+ // back-row zones at depth 6.5), which lands on a stored DEEP neighbour. Filling from the sim's per-rotation default
43
+ // (what the editor's coordOf now does, matching what the sim always did) keeps it legal.
44
+ describe('unset RECEIVE zone fallback (editor gap fill)', () => {
45
+ // The reported user's real rotation-2 RECEIVE: zone 3 sits abnormally deep at d 6.5; zone 6 is unset.
46
+ const partialRot2 = {
47
+ 1: { d: 6.4, r: 3.15 }, 2: { d: 2.75, r: 1.65 }, 3: { d: 6.5, r: -2.65 }, 4: { d: 2.75, r: -2.75 }, 5: { d: 2.8, r: -3.05 }
48
+ };
49
+ const FLAT_GRID_ZONE6 = { d: 6.5, r: 0 }; // the editor's OLD blank-slate default for a back-row zone
50
+ const SIM_DEFAULT_ZONE6 = (0, default_base_config_1.defaultBaseConfig)(rotation_system_1.RotationSystemEnum.FIVE_ONE).coords.RECEIVE['2']['6']; // the fix's fallback
51
+ const violationsWithZone6 = (zone6) => (0, receive_overlap_1.baseConfigViolations)({ coords: { RECEIVE: { 2: { ...partialRot2, 6: zone6 } } } });
52
+ it('collides (zones 3,6) when the gap is filled from the flat grid - the bug', () => {
53
+ expect(violationsWithZone6(FLAT_GRID_ZONE6).map(v => v.zones)).toEqual([[3, 6]]);
54
+ });
55
+ it('stays legal when the gap is filled from the sim per-rotation default - the fix', () => {
56
+ expect(violationsWithZone6(SIM_DEFAULT_ZONE6)).toEqual([]);
57
+ });
58
+ });
7
59
  // The trajectory-set default desired-contact (owner D9, 2026-08-12): a near-net hitting point in each attacker's
8
60
  // lane, NOT the old set-to-the-standing-dot location. Front row contacts sit ~0.5 m off the net; back row on the
9
61
  // ~1.8 m pipe line; the lateral is the ATTACK-dot's r clamped inside the antenna (|r| <= 4.3).
@@ -60,7 +60,7 @@ export interface ApiPlayerInjuryStatus {
60
60
  severity: number;
61
61
  injuredUntil: string;
62
62
  }
63
- export type ChampionRegion = 'azureich' | 'borealas' | 'varune' | 'tirdeas';
63
+ export type ChampionRegion = 'azureich' | 'borealas' | 'varune' | 'tirdeas' | 'kyorin';
64
64
  export type ChampionBadgeSpec = {
65
65
  type: 'league';
66
66
  } | {
@@ -47,6 +47,18 @@
47
47
  "XP": [
48
48
  "nl_NL"
49
49
  ],
50
+ "XQ": [
51
+ "pl_PL"
52
+ ],
53
+ "XR": [
54
+ "th_TH"
55
+ ],
56
+ "XS": [
57
+ "ar_SA"
58
+ ],
59
+ "XT": [
60
+ "en_IN"
61
+ ],
50
62
  "PK": [
51
63
  "bal_Arab_PK",
52
64
  "bal_Latn_PK",
@@ -1,4 +1,4 @@
1
- import { AuthIdentityModel, AuthSessionModel, AuthUserModel, BoxScoreModel, CoachModel, ScoutModel, PhysicianModel, PhysiotherapistModel, BoxScoreTotalsModel, CompetitionChampionModel, JerseyBadgeModel, CurrencyTransactionModel, GachaPityModel, GachaPullHistoryModel, WishlistModel, UserSettingsModel, UserDeviceTokenModel, BasePositionPresetModel, CompetitionMatchModel, CompetitionMVPModel, CompetitionModel, CompetitionStandingsMatchModel, CompetitionStandingsModel, CompetitionTeamsModel, CountryModel, DivisionModel, DivisionSeasonModel, IterationModel, LeagueModel, MatchModel, MatchRatingModel, MatchResultModel, MatchSetModel, PerformanceStatsModel, PlayerModel, PlayerImprovementLogModel, PlayerDeclineLogModel, PlayerTeamModel, RetiredPlayerModel, PromotionMatchModel, FriendlyModel, RegionQualifierModel, NationalCountryModel, RallyModel, TacticsModel, TeamJerseyModel, LineupPresetModel, MatchPresetModel, EventModel, EventCompetitionModel, EventSquadModel, EventPackModel, EventPackPlayerModel, EventResultModel, EventBotTeamModel, TeamModel, TeamReplacementModel, UserCurrencyModel, UserPlayerProgressModel, UserPullGrantModel, PremiumGrantModel, UserTeamModel, VPERModel, NotificationModel, AppConfigModel, LegacyTeamFlagModel } from './models';
1
+ import { AuthIdentityModel, AuthSessionModel, AuthUserModel, BoxScoreModel, CoachModel, ScoutModel, PhysicianModel, PhysiotherapistModel, BoxScoreTotalsModel, CompetitionChampionModel, JerseyBadgeModel, CurrencyTransactionModel, GachaPityModel, GachaPullHistoryModel, WishlistModel, UserSettingsModel, UserAchievementsModel, UserDeviceTokenModel, BasePositionPresetModel, CompetitionMatchModel, CompetitionMVPModel, CompetitionModel, CompetitionStandingsMatchModel, CompetitionStandingsModel, CompetitionTeamsModel, CountryModel, DivisionModel, DivisionSeasonModel, IterationModel, LeagueModel, MatchModel, MatchRatingModel, MatchResultModel, MatchSetModel, PerformanceStatsModel, PlayerModel, PlayerImprovementLogModel, PlayerDeclineLogModel, PlayerTeamModel, RetiredPlayerModel, PromotionMatchModel, FriendlyModel, RegionQualifierModel, NationalCountryModel, RallyModel, TacticsModel, TeamJerseyModel, LineupPresetModel, MatchPresetModel, EventModel, EventCompetitionModel, EventSquadModel, EventPackModel, EventPackPlayerModel, EventResultModel, EventBotTeamModel, TeamModel, TeamReplacementModel, UserCurrencyModel, UserPlayerProgressModel, UserPullGrantModel, PremiumGrantModel, UserTeamModel, VPERModel, NotificationModel, AppConfigModel, LegacyTeamFlagModel } from './models';
2
2
  export function initModels(sequelize) {
3
3
  const Coach = CoachModel.initModel(sequelize);
4
4
  const Scout = ScoutModel.initModel(sequelize);
@@ -25,6 +25,7 @@ export function initModels(sequelize) {
25
25
  const PremiumGrant = PremiumGrantModel.initModel(sequelize);
26
26
  const Wishlist = WishlistModel.initModel(sequelize);
27
27
  const UserSettings = UserSettingsModel.initModel(sequelize);
28
+ const UserAchievements = UserAchievementsModel.initModel(sequelize);
28
29
  const UserDeviceToken = UserDeviceTokenModel.initModel(sequelize);
29
30
  const BasePositionPreset = BasePositionPresetModel.initModel(sequelize);
30
31
  const Notification = NotificationModel.initModel(sequelize);
@@ -75,6 +76,7 @@ export function initModels(sequelize) {
75
76
  AuthUser.hasOne(GachaPity, { as: 'GachaPity', foreignKey: 'user_id' });
76
77
  AuthUser.hasOne(Wishlist, { as: 'Wishlist', foreignKey: 'user_id' });
77
78
  AuthUser.hasOne(UserSettings, { as: 'UserSettings', foreignKey: 'user_id' });
79
+ AuthUser.hasOne(UserAchievements, { as: 'UserAchievements', foreignKey: 'user_id' });
78
80
  AuthUser.hasMany(UserDeviceToken, { as: 'UserDeviceTokens', foreignKey: 'user_id' });
79
81
  AuthUser.hasMany(BasePositionPreset, { as: 'BasePositionPresets', foreignKey: 'user_id' });
80
82
  AuthUser.hasMany(Notification, { as: 'Notifications', foreignKey: 'user_id' });
@@ -98,6 +100,7 @@ export function initModels(sequelize) {
98
100
  PremiumGrant.belongsTo(AuthUser, { as: 'grantedByUser', foreignKey: 'granted_by' });
99
101
  Wishlist.belongsTo(AuthUser, { as: 'user', foreignKey: 'user_id' });
100
102
  UserSettings.belongsTo(AuthUser, { as: 'user', foreignKey: 'user_id' });
103
+ UserAchievements.belongsTo(AuthUser, { as: 'user', foreignKey: 'user_id' });
101
104
  Competition.belongsTo(Iteration, { as: 'Iteration', foreignKey: 'iteration' });
102
105
  Competition.hasMany(CompetitionMatch, { as: 'CompetitionMatches', foreignKey: 'competition_id' });
103
106
  Competition.hasMany(CompetitionStandings, { as: 'CompetitionStandings', foreignKey: 'competition_id' });
@@ -367,6 +370,7 @@ export function initModels(sequelize) {
367
370
  PremiumGrant,
368
371
  Wishlist,
369
372
  UserSettings,
373
+ UserAchievements,
370
374
  UserDeviceToken,
371
375
  BasePositionPreset,
372
376
  Notification,
@@ -17,9 +17,10 @@ export declare enum CurrencyTransactionSourceEnum {
17
17
  SEASON_REWARD = "SEASON_REWARD",
18
18
  COMPETITION_REWARD = "COMPETITION_REWARD",
19
19
  PHYSICIAN_UPGRADE = "PHYSICIAN_UPGRADE",
20
- PHYSIOTHERAPIST_UPGRADE = "PHYSIOTHERAPIST_UPGRADE"
20
+ PHYSIOTHERAPIST_UPGRADE = "PHYSIOTHERAPIST_UPGRADE",
21
+ ACHIEVEMENT_CLAIM = "ACHIEVEMENT_CLAIM"
21
22
  }
22
- export type CurrencyTransactionSource = CurrencyTransactionSourceEnum.MATCH_REWARD | CurrencyTransactionSourceEnum.GACHA_PULL | CurrencyTransactionSourceEnum.COACH_UPGRADE | CurrencyTransactionSourceEnum.SCOUT_UPGRADE | CurrencyTransactionSourceEnum.PLAYER_DISMISS | CurrencyTransactionSourceEnum.PLAYER_RETIRE | CurrencyTransactionSourceEnum.ADMIN_GRANT | CurrencyTransactionSourceEnum.SEASON_REWARD | CurrencyTransactionSourceEnum.COMPETITION_REWARD | CurrencyTransactionSourceEnum.PHYSICIAN_UPGRADE | CurrencyTransactionSourceEnum.PHYSIOTHERAPIST_UPGRADE;
23
+ export type CurrencyTransactionSource = CurrencyTransactionSourceEnum.MATCH_REWARD | CurrencyTransactionSourceEnum.GACHA_PULL | CurrencyTransactionSourceEnum.COACH_UPGRADE | CurrencyTransactionSourceEnum.SCOUT_UPGRADE | CurrencyTransactionSourceEnum.PLAYER_DISMISS | CurrencyTransactionSourceEnum.PLAYER_RETIRE | CurrencyTransactionSourceEnum.ADMIN_GRANT | CurrencyTransactionSourceEnum.SEASON_REWARD | CurrencyTransactionSourceEnum.COMPETITION_REWARD | CurrencyTransactionSourceEnum.PHYSICIAN_UPGRADE | CurrencyTransactionSourceEnum.PHYSIOTHERAPIST_UPGRADE | CurrencyTransactionSourceEnum.ACHIEVEMENT_CLAIM;
23
24
  export interface CurrencyTransactionAttributes {
24
25
  transaction_id: string;
25
26
  user_id: string;
@@ -17,6 +17,7 @@ export var CurrencyTransactionSourceEnum;
17
17
  CurrencyTransactionSourceEnum["COMPETITION_REWARD"] = "COMPETITION_REWARD";
18
18
  CurrencyTransactionSourceEnum["PHYSICIAN_UPGRADE"] = "PHYSICIAN_UPGRADE";
19
19
  CurrencyTransactionSourceEnum["PHYSIOTHERAPIST_UPGRADE"] = "PHYSIOTHERAPIST_UPGRADE";
20
+ CurrencyTransactionSourceEnum["ACHIEVEMENT_CLAIM"] = "ACHIEVEMENT_CLAIM";
20
21
  })(CurrencyTransactionSourceEnum || (CurrencyTransactionSourceEnum = {}));
21
22
  export class CurrencyTransactionModel extends Model {
22
23
  static initModel(sequelize) {
@@ -58,6 +58,7 @@ export * from './wishlist';
58
58
  export * from './currency-transaction';
59
59
  export * from './user-pull-grant';
60
60
  export * from './user-settings';
61
+ export * from './user-achievements';
61
62
  export * from './user-device-token';
62
63
  export * from './notification';
63
64
  export * from './vper';
@@ -58,6 +58,7 @@ export * from './wishlist';
58
58
  export * from './currency-transaction';
59
59
  export * from './user-pull-grant';
60
60
  export * from './user-settings';
61
+ export * from './user-achievements';
61
62
  export * from './user-device-token';
62
63
  export * from './notification';
63
64
  export * from './vper';
@@ -0,0 +1,19 @@
1
+ import * as Sequelize from 'sequelize';
2
+ import { Model } from 'sequelize';
3
+ import { AuthUserId, AuthUserModel } from '.';
4
+ export interface UserAchievementsAttributes {
5
+ user_id: string;
6
+ achievements: Record<string, string>;
7
+ }
8
+ export type UserAchievementsPk = 'user_id';
9
+ export type UserAchievementsId = UserAchievementsModel[UserAchievementsPk];
10
+ export type UserAchievementsCreationAttributes = Sequelize.Optional<UserAchievementsAttributes, 'achievements'>;
11
+ export declare class UserAchievementsModel extends Model<UserAchievementsAttributes, UserAchievementsCreationAttributes> implements UserAchievementsAttributes {
12
+ user_id: string;
13
+ achievements: Record<string, string>;
14
+ user: AuthUserModel;
15
+ getUser: Sequelize.BelongsToGetAssociationMixin<AuthUserModel>;
16
+ setUser: Sequelize.BelongsToSetAssociationMixin<AuthUserModel, AuthUserId>;
17
+ createUser: Sequelize.BelongsToCreateAssociationMixin<AuthUserModel>;
18
+ static initModel(sequelize: Sequelize.Sequelize): typeof UserAchievementsModel;
19
+ }
@@ -0,0 +1,33 @@
1
+ import { DataTypes, Model } from 'sequelize';
2
+ export class UserAchievementsModel extends Model {
3
+ static initModel(sequelize) {
4
+ return UserAchievementsModel.init({
5
+ user_id: {
6
+ type: DataTypes.UUID,
7
+ allowNull: false,
8
+ primaryKey: true,
9
+ references: {
10
+ model: 'AuthUser',
11
+ key: 'user_id'
12
+ }
13
+ },
14
+ achievements: {
15
+ type: DataTypes.JSONB,
16
+ allowNull: false,
17
+ defaultValue: {}
18
+ }
19
+ }, {
20
+ sequelize,
21
+ tableName: 'UserAchievements',
22
+ schema: 'public',
23
+ timestamps: false,
24
+ indexes: [
25
+ {
26
+ name: 'UserAchievements_pk',
27
+ unique: true,
28
+ fields: [{ name: 'user_id' }]
29
+ }
30
+ ]
31
+ });
32
+ }
33
+ }
@@ -0,0 +1,5 @@
1
+ export declare enum AchievementStatus {
2
+ NOT_DONE = "NOT_DONE",
3
+ DONE = "DONE",
4
+ CLAIMED = "CLAIMED"
5
+ }
@@ -0,0 +1,10 @@
1
+ // Per-user achievement progress state. Stored only inside the UserAchievements.achievements jsonb map
2
+ // (achievementKey -> status); an absent key means NOT_DONE, so no data backfill is ever needed when new
3
+ // achievements are added. Because it lives only inside jsonb it is NOT a Sequelize DataTypes.ENUM and has no
4
+ // Postgres CREATE TYPE (mirrors how Tactics stores RoleEnum values inside jsonb without a DB enum).
5
+ export var AchievementStatus;
6
+ (function (AchievementStatus) {
7
+ AchievementStatus["NOT_DONE"] = "NOT_DONE";
8
+ AchievementStatus["DONE"] = "DONE";
9
+ AchievementStatus["CLAIMED"] = "CLAIMED";
10
+ })(AchievementStatus || (AchievementStatus = {}));
@@ -0,0 +1,24 @@
1
+ import { AchievementStatus } from './achievement-status';
2
+ export declare const ACHIEVEMENT_KEYS: readonly ["NORMAL_RECRUIT", "SPECIAL_RECRUIT", "DISMISS_PLAYER", "RETIRE_PLAYER", "GIVE_NICKNAME", "PLAY_MATCH", "WIN_MATCH", "FINISH_LEAGUE_SEASON", "UPGRADE_COACH", "UPGRADE_SCOUT", "UPGRADE_PHYSICIAN", "UPGRADE_PHYSIOTHERAPIST", "CHANGE_TRAINING_FOCUS", "CHANGE_TACTICS", "REGISTER_EVENT", "WIN_LEAGUE", "WIN_NATIONALS", "WIN_REGIONALS", "WIN_WCC", "READ_WIKI", "CLAIM_DAILY", "VIEW_MATCH", "PREVIEW_MATCH"];
3
+ export type AchievementKey = typeof ACHIEVEMENT_KEYS[number];
4
+ export declare const ACHIEVEMENT_REWARD = 500;
5
+ export declare const MILESTONE_ACHIEVEMENTS: readonly AchievementKey[];
6
+ export declare function isMilestone(key: AchievementKey): boolean;
7
+ export declare const MILESTONE_THRESHOLDS: readonly number[];
8
+ export declare function nextMilestone(claimedLevel: number): number | null;
9
+ export interface MilestoneProgress {
10
+ c: number;
11
+ l: number;
12
+ }
13
+ export interface MilestoneState {
14
+ count: number;
15
+ claimed: number;
16
+ target: number | null;
17
+ claimable: boolean;
18
+ maxed: boolean;
19
+ }
20
+ export declare function milestoneState(value: unknown): MilestoneState;
21
+ export type AchievementMap = Partial<Record<AchievementKey, AchievementStatus | MilestoneProgress>>;
22
+ export declare const CLIENT_COMPLETABLE_ACHIEVEMENTS: readonly AchievementKey[];
23
+ export declare function isAchievementKey(value: string): value is AchievementKey;
24
+ export declare function isClientCompletable(key: AchievementKey): boolean;
@@ -0,0 +1,77 @@
1
+ // Single source of truth for the set of achievement keys (the tutorial checklist). Adding an achievement:
2
+ // 1. add its key here,
3
+ // 2. add its display config in the UI (src/view/achievements/achievements-config.ts),
4
+ // 3. add its name + description to every locale file,
5
+ // 4. wire its completion trigger (API handler or sim engine) to call completeAchievement.
6
+ // The key is only ever a jsonb map key, so there is no Postgres enum/column to migrate when the list grows.
7
+ export const ACHIEVEMENT_KEYS = [
8
+ 'NORMAL_RECRUIT',
9
+ 'SPECIAL_RECRUIT',
10
+ 'DISMISS_PLAYER',
11
+ 'RETIRE_PLAYER',
12
+ 'GIVE_NICKNAME',
13
+ 'PLAY_MATCH',
14
+ 'WIN_MATCH',
15
+ 'FINISH_LEAGUE_SEASON',
16
+ 'UPGRADE_COACH',
17
+ 'UPGRADE_SCOUT',
18
+ 'UPGRADE_PHYSICIAN',
19
+ 'UPGRADE_PHYSIOTHERAPIST',
20
+ 'CHANGE_TRAINING_FOCUS',
21
+ 'CHANGE_TACTICS',
22
+ 'REGISTER_EVENT',
23
+ 'WIN_LEAGUE',
24
+ 'WIN_NATIONALS',
25
+ 'WIN_REGIONALS',
26
+ 'WIN_WCC',
27
+ 'READ_WIKI',
28
+ 'CLAIM_DAILY',
29
+ 'VIEW_MATCH',
30
+ 'PREVIEW_MATCH'
31
+ ];
32
+ // Every achievement grants the same flat coin reward when the user claims it (each milestone reached is a fresh claim).
33
+ export const ACHIEVEMENT_REWARD = 500;
34
+ // Recurring "milestone" achievements: instead of a one-shot NOT_DONE->DONE->CLAIMED status, these store a running count
35
+ // and re-arm at escalating thresholds. Their map value is a MilestoneProgress object, not a status string.
36
+ export const MILESTONE_ACHIEVEMENTS = [
37
+ 'CLAIM_DAILY',
38
+ 'PLAY_MATCH',
39
+ 'WIN_MATCH',
40
+ 'NORMAL_RECRUIT',
41
+ 'DISMISS_PLAYER',
42
+ 'WIN_LEAGUE',
43
+ 'WIN_NATIONALS',
44
+ 'WIN_REGIONALS',
45
+ 'WIN_WCC'
46
+ ];
47
+ export function isMilestone(key) {
48
+ return MILESTONE_ACHIEVEMENTS.includes(key);
49
+ }
50
+ // The claim thresholds every milestone steps through (x5, x2 alternating). Past the last one the milestone is "maxed".
51
+ export const MILESTONE_THRESHOLDS = [1, 5, 10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000, 500000, 1000000];
52
+ // The next threshold strictly greater than `claimedLevel` (0 = nothing claimed yet), or null when all are claimed.
53
+ export function nextMilestone(claimedLevel) {
54
+ return MILESTONE_THRESHOLDS.find(t => t > claimedLevel) ?? null;
55
+ }
56
+ // Derive a milestone's display/claim state from its raw map value (a MilestoneProgress object, a legacy status string,
57
+ // or undefined). Anything that is not a well-formed object counts as no progress ({c:0, l:0}). Shared by the API claim
58
+ // path and the UI.
59
+ export function milestoneState(value) {
60
+ const mp = (value != null && typeof value === 'object') ? value : {};
61
+ const count = typeof mp.c === 'number' && Number.isFinite(mp.c) ? mp.c : 0;
62
+ const claimed = typeof mp.l === 'number' && Number.isFinite(mp.l) ? mp.l : 0;
63
+ const target = nextMilestone(claimed);
64
+ return { count, claimed, target, claimable: target != null && count >= target, maxed: target == null };
65
+ }
66
+ // Achievements the client is allowed to self-complete because there is no server-side action to hook: opening the
67
+ // wiki and opening a match detail. The POST /achievements/:key/complete endpoint MUST reject any key not in this set,
68
+ // so a client can never self-complete a server-authoritative achievement (recruit, win, upgrade, ...).
69
+ export const CLIENT_COMPLETABLE_ACHIEVEMENTS = ['READ_WIKI', 'VIEW_MATCH', 'PREVIEW_MATCH'];
70
+ // Whether `value` is one of the known achievement keys (use before trusting a request param).
71
+ export function isAchievementKey(value) {
72
+ return ACHIEVEMENT_KEYS.includes(value);
73
+ }
74
+ // Whether the client is allowed to fire completion for this key (only READ_WIKI / VIEW_MATCH).
75
+ export function isClientCompletable(key) {
76
+ return CLIENT_COMPLETABLE_ACHIEVEMENTS.includes(key);
77
+ }
@@ -0,0 +1,3 @@
1
+ export * from './achievement-status';
2
+ export * from './achievement-type';
3
+ export * from './schemas/achievements.z';
@@ -0,0 +1,3 @@
1
+ export * from './achievement-status';
2
+ export * from './achievement-type';
3
+ export * from './schemas/achievements.z';
@@ -0,0 +1,3 @@
1
+ import { z } from 'zod';
2
+ import { AchievementStatus } from '../achievement-status';
3
+ export declare const achievementMapSchema: z.ZodRecord<z.ZodString, z.ZodEnum<typeof AchievementStatus>>;
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ import { AchievementStatus } from '../achievement-status';
3
+ // Validates a UserAchievements.achievements jsonb map (achievementKey -> status). Keys are loose strings so an
4
+ // unknown/legacy key never fails validation; values must be a known AchievementStatus. Mirrors the z.record idiom
5
+ // used by Tactics (tactics.z.ts).
6
+ export const achievementMapSchema = z.record(z.string(), z.nativeEnum(AchievementStatus));
@@ -6,4 +6,5 @@ export * from './player';
6
6
  export * from './competition';
7
7
  export * from './notification';
8
8
  export * from './premium';
9
+ export * from './achievement';
9
10
  export * from './utils';
@@ -6,4 +6,5 @@ export * from './player';
6
6
  export * from './competition';
7
7
  export * from './notification';
8
8
  export * from './premium';
9
+ export * from './achievement';
9
10
  export * from './utils';
@@ -10,11 +10,14 @@ const WEIGHTS = {
10
10
  BLOCK_TOUCH: 0.2,
11
11
  DEFENSE_DIG: 0.25,
12
12
  SETTING_PASS: 0.15,
13
+ // A swing that neither killed nor was stuffed nor sailed: the ball is still alive and the defence had to play it.
14
+ ATTACK_IN_PLAY: 0.10,
13
15
  // reception quality
14
16
  RECEPTION_PERFECT: 0.35,
15
17
  RECEPTION_POSITIVE: 0.15,
16
18
  RECEPTION_OVERPASS: -0.6,
17
- // terminal errors (always point against)
19
+ // terminal errors (always point against). A stuffed attack is booked as an attack error by the simulator, so
20
+ // being blocked is penalised here at the same -1.0 as a swing into the net.
18
21
  TERMINAL_ERROR: -1.0
19
22
  };
20
23
  // Minimum points a player must be on court for in a match to receive a VPER. VPER is a per-100-points rate,
@@ -44,14 +47,31 @@ export class VPER {
44
47
  let pointsPlayedSum = 0;
45
48
  for (const box of boxScores) {
46
49
  const w = WEIGHTS;
50
+ // Each action is scored ONCE, at its highest-value outcome. A set that became a kill is an assist, not an
51
+ // assist plus a pass; a touch that became a stuff is a block point, not a block point plus a touch. The
52
+ // subtractions below strip the lower-value credit that the simulator books alongside the terminal one.
53
+ //
54
+ // Each remainder is clamped at 0. A well-formed row can never make one negative (a swing yields at most one
55
+ // of {kill, error}; every assist belongs to a set that booked a pass on this same row; BlockStatsSchema
56
+ // enforces solo + assists <= touches), so the clamp only guards against a malformed or hand-built row
57
+ // silently turning a subtraction into a bonus.
58
+ //
59
+ // A swing that stayed alive: attempts minus the ones that terminated (a kill, or an error, which now
60
+ // includes being stuffed).
61
+ const inPlayAttacks = Math.max(0, box.attack.attempts - box.attack.kills - box.attack.errors);
62
+ // Sets that did NOT convert into a kill.
63
+ const unconvertedSets = Math.max(0, box.setting.pass - box.setting.assists);
64
+ // Touches that did not become a block point.
65
+ const nonScoringTouches = Math.max(0, box.block.touches - box.block.solo - box.block.assists);
47
66
  const positive = w.ATTACK_KILL * box.attack.kills +
67
+ w.ATTACK_IN_PLAY * inPlayAttacks +
48
68
  w.SERVE_ACE * box.serve.aces +
49
69
  w.BLOCK_SOLO * box.block.solo +
50
70
  w.BLOCK_ASSIST * box.block.assists +
51
71
  w.SETTING_ASSIST * box.setting.assists +
52
- w.BLOCK_TOUCH * box.block.touches +
72
+ w.BLOCK_TOUCH * nonScoringTouches +
53
73
  w.DEFENSE_DIG * box.defense.digs +
54
- w.SETTING_PASS * box.setting.pass +
74
+ w.SETTING_PASS * unconvertedSets +
55
75
  w.RECEPTION_PERFECT * box.reception.perfect +
56
76
  w.RECEPTION_POSITIVE * box.reception.positive;
57
77
  const negative = w.RECEPTION_OVERPASS * box.reception.overpasses +
@@ -104,6 +104,92 @@ describe('VPER.computeVPERForMatch()', () => {
104
104
  expect(VPER.computeVPERForMatch([box])).toBeCloseTo(14, 5);
105
105
  });
106
106
  });
107
+ // ─── each action scores once, at its highest-value outcome ────────────────────
108
+ describe('VPER.computeVPERForMatch() — no double-counting', () => {
109
+ it('scores a converted set as an assist only, never assist + pass', () => {
110
+ const box = makeBox();
111
+ box.setting.pass = 1;
112
+ box.setting.assists = 1;
113
+ box.misc.pointsPlayed = 10;
114
+ // unconvertedSets = 1 - 1 = 0, so the setter earns 0.7, not 0.85.
115
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(7, 5);
116
+ });
117
+ it('pays the pass weight only on sets that did not convert', () => {
118
+ const box = makeBox();
119
+ box.setting.pass = 10;
120
+ box.setting.assists = 4;
121
+ box.misc.pointsPlayed = 10;
122
+ // assists 4 * 0.7 = 2.8; unconverted 6 * 0.15 = 0.9; raw 3.7
123
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(37, 5);
124
+ });
125
+ it('scores a solo stuff as a block point only, never solo + touch', () => {
126
+ const box = makeBox();
127
+ box.block.touches = 1;
128
+ box.block.solo = 1;
129
+ box.misc.pointsPlayed = 10;
130
+ // nonScoringTouches = 1 - 1 - 0 = 0, so the blocker earns 1.0, not 1.2.
131
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(10, 5);
132
+ });
133
+ it('pays the touch weight only on touches that did not become a point', () => {
134
+ const box = makeBox();
135
+ box.block.touches = 5;
136
+ box.block.solo = 1;
137
+ box.block.assists = 2;
138
+ box.misc.pointsPlayed = 10;
139
+ // solo 1.0 + assists 2 * 0.5 = 1.0 + nonScoringTouches 2 * 0.2 = 0.4; raw 2.4
140
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(24, 5);
141
+ });
142
+ it('never lets a malformed row turn a subtraction into a bonus', () => {
143
+ // kills without attempts, assists without passes, blocks without touches: every remainder clamps to 0.
144
+ const box = makeBox();
145
+ box.attack.kills = 5;
146
+ box.setting.assists = 10;
147
+ box.block.solo = 3;
148
+ box.misc.pointsPlayed = 10;
149
+ // 5 * 1.0 + 10 * 0.7 + 3 * 1.0 = 15, with no negative remainder contribution
150
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(150, 5);
151
+ });
152
+ });
153
+ // ─── the non-scoring attack ──────────────────────────────────────────────────
154
+ describe('VPER.computeVPERForMatch() — attack outcomes', () => {
155
+ it('rewards a swing that stays in play (weight 0.10)', () => {
156
+ const box = makeBox();
157
+ box.attack.attempts = 10;
158
+ box.attack.kills = 3;
159
+ box.attack.errors = 1;
160
+ box.misc.pointsPlayed = 10;
161
+ // kills 3 * 1.0 = 3; inPlay (10 - 3 - 1) = 6 * 0.10 = 0.6; errors 1 * -1.0; raw 2.6
162
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(26, 5);
163
+ });
164
+ it('penalises a stuffed swing at -1.0 (the simulator books it as an attack error)', () => {
165
+ const box = makeBox();
166
+ box.attack.attempts = 1;
167
+ box.attack.errors = 1;
168
+ box.misc.pointsPlayed = 10;
169
+ // inPlay = 0; the stuff is a terminal error
170
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(-10, 5);
171
+ });
172
+ it('separates volume from efficiency: more dug swings for the same kills rates higher', () => {
173
+ const efficient = makeBox();
174
+ efficient.attack.attempts = 12;
175
+ efficient.attack.kills = 10;
176
+ efficient.misc.pointsPlayed = 100;
177
+ const volume = makeBox();
178
+ volume.attack.attempts = 30;
179
+ volume.attack.kills = 10;
180
+ volume.misc.pointsPlayed = 100;
181
+ // Pre-fix both scored exactly 10.0. Now the extra 18 dug swings are worth 0.10 each.
182
+ expect(VPER.computeVPERForMatch([efficient])).toBeCloseTo(10.2, 5);
183
+ expect(VPER.computeVPERForMatch([volume])).toBeCloseTo(12.0, 5);
184
+ });
185
+ it('a swing that is killed scores the kill, not the kill plus an in-play credit', () => {
186
+ const box = makeBox();
187
+ box.attack.attempts = 1;
188
+ box.attack.kills = 1;
189
+ box.misc.pointsPlayed = 10;
190
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(10, 5);
191
+ });
192
+ });
107
193
  // ─── VPER eligibility threshold ───────────────────────────────────────────────
108
194
  describe('VPER.qualifiesForVPER()', () => {
109
195
  function boxWithPoints(points) {
@@ -58,5 +58,9 @@
58
58
  "title": "Event finished",
59
59
  "body": "The event is over. See the final table and your rewards.",
60
60
  "placed": "The event is over. You finished {{position}} of {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Your team is moving",
64
+ "body": "Your team has been chosen to be moved to the new region: Kyorin. Nothing has changed in terms of the team you manage, your players or anything else."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento terminado",
59
59
  "body": "El evento ha terminado. Mira la tabla final y tus recompensas.",
60
60
  "placed": "El evento ha terminado. Has quedado {{position}} de {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Tu equipo se traslada",
64
+ "body": "Tu equipo ha sido elegido para trasladarse a la nueva región: Kyorin. No cambia nada en cuanto al equipo que gestionas, tus jugadores ni nada más."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Événement terminé",
59
59
  "body": "L'événement est terminé. Consulte le classement final et tes récompenses.",
60
60
  "placed": "L'événement est terminé. Tu finis {{position}} sur {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Votre équipe déménage",
64
+ "body": "Votre équipe a été choisie pour être transférée vers la nouvelle région : Kyorin. Rien ne change concernant l'équipe que vous gérez, vos joueurs ni quoi que ce soit d'autre."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento concluso",
59
59
  "body": "L'evento è finito. Guarda la classifica finale e i tuoi premi.",
60
60
  "placed": "L'evento è finito. Hai chiuso {{position}} su {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "La tua squadra si trasferisce",
64
+ "body": "La tua squadra è stata scelta per essere spostata nella nuova regione: Kyorin. Non cambia nulla riguardo alla squadra che gestisci, ai tuoi giocatori o a qualsiasi altra cosa."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Wydarzenie zakończone",
59
59
  "body": "Wydarzenie się skończyło. Zobacz tabelę końcową i nagrody.",
60
60
  "placed": "Wydarzenie się skończyło. Zająłeś {{position}} miejsce na {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Twoja drużyna się przenosi",
64
+ "body": "Twoja drużyna została wybrana do przeniesienia do nowego regionu: Kyorin. Nic się nie zmienia, jeśli chodzi o zarządzaną przez ciebie drużynę, twoich zawodników ani cokolwiek innego."
61
65
  }
62
66
  }