coh-content-db 2.0.0-rc.9 → 2.0.0

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 (66) hide show
  1. package/.github/workflows/build.yml +3 -1
  2. package/CHANGELOG.md +11 -2
  3. package/README.md +33 -19
  4. package/dist/coh-content-db.d.ts +230 -170
  5. package/dist/coh-content-db.js +495 -296
  6. package/dist/coh-content-db.js.map +1 -1
  7. package/dist/coh-content-db.mjs +488 -294
  8. package/dist/coh-content-db.mjs.map +1 -1
  9. package/jest.config.mjs +1 -0
  10. package/package.json +14 -14
  11. package/src/main/api/badge-data.ts +13 -7
  12. package/src/main/api/bundle-data.ts +1 -1
  13. package/src/main/api/bundle-header-data.ts +13 -6
  14. package/src/main/api/contact-data.ts +2 -1
  15. package/src/main/api/level-range-data.ts +4 -0
  16. package/src/main/api/mission-data.ts +3 -29
  17. package/src/main/api/mission-flashback-data.ts +31 -0
  18. package/src/main/api/morality.ts +27 -9
  19. package/src/main/api/set-title-data.ts +4 -0
  20. package/src/main/api/variant-context.ts +11 -0
  21. package/src/main/api/{alternate-data.ts → variant-data.ts} +4 -4
  22. package/src/main/api/zone-data.ts +24 -0
  23. package/src/main/api/zone-type.ts +59 -0
  24. package/src/main/db/abstract-index.ts +12 -16
  25. package/src/main/db/badge-index.ts +53 -27
  26. package/src/main/db/badge-requirement.ts +1 -1
  27. package/src/main/db/badge-search-options.ts +15 -14
  28. package/src/main/db/badge.ts +46 -29
  29. package/src/main/db/bundle-header.ts +18 -10
  30. package/src/main/db/coh-content-database.ts +17 -17
  31. package/src/main/db/contact.ts +5 -4
  32. package/src/main/db/level-range.ts +15 -0
  33. package/src/main/db/mission.ts +11 -10
  34. package/src/main/db/paged.ts +7 -3
  35. package/src/main/db/set-title-ids.ts +10 -0
  36. package/src/main/db/variants.ts +84 -0
  37. package/src/main/db/zone.ts +29 -0
  38. package/src/main/index.ts +11 -4
  39. package/src/main/util/coalesce-to-array.ts +13 -0
  40. package/src/main/{util.ts → util/links.ts} +8 -22
  41. package/src/main/util/to-date.ts +9 -0
  42. package/src/test/api/alignment.test.ts +2 -2
  43. package/src/test/api/badge-data.fixture.ts +1 -0
  44. package/src/test/api/badge-data.test.ts +1 -0
  45. package/src/test/api/bundle-data.fixture.ts +3 -2
  46. package/src/test/api/bundle-header-data.fixture.ts +4 -2
  47. package/src/test/api/morality.test.ts +31 -0
  48. package/src/test/api/sex.test.ts +2 -2
  49. package/src/test/api/zone-data.fixture.ts +1 -0
  50. package/src/test/db/abstract-index.test.ts +12 -43
  51. package/src/test/db/badge-index.test.ts +197 -101
  52. package/src/test/db/badge.test.ts +122 -16
  53. package/src/test/db/bundle-header.test.ts +25 -12
  54. package/src/test/db/coh-content-database.test.ts +134 -175
  55. package/src/test/db/contact.test.ts +2 -1
  56. package/src/test/db/level-range.test.ts +47 -0
  57. package/src/test/db/mission.test.ts +8 -6
  58. package/src/test/db/morality-list.test.ts +1 -1
  59. package/src/test/db/set-title-ids.test.ts +19 -0
  60. package/src/test/db/{alternates.test.ts → variants.test.ts} +24 -24
  61. package/src/test/db/zone.test.ts +45 -0
  62. package/src/test/integration.test.ts +3 -3
  63. package/src/test/util/coalese-to-array.test.ts +17 -0
  64. package/src/test/{util.test.ts → util/links.test.ts} +5 -21
  65. package/src/test/util/to-date.test.ts +15 -0
  66. package/src/main/db/alternates.ts +0 -67
@@ -1,31 +1,49 @@
1
+ import { Alignment } from './alignment'
2
+
1
3
  export const MORALITY = ['hero', 'vigilante', 'villain', 'rogue', 'resistance', 'loyalist'] as const
2
- export type Morality = typeof MORALITY[number]
3
- export type MoralityExtended = Morality
4
+ export const MORALITY_EXTENDED = [
5
+ ...MORALITY,
4
6
  /**
5
7
  * Any of the Primal Earth moralities - Hero, Vigilante, Villain, Rogue.
6
8
  */
7
- | 'primal'
9
+ 'primal',
8
10
  /**
9
11
  * Either of the Praetorian Earth moralities - Resistance or Loyalist.
10
12
  */
11
- | 'praetorian'
13
+ 'praetorian',
12
14
  /**
13
15
  * The moralities that roll up to the Hero {@link Alignment} - Hero and Vigilante.
14
16
  */
15
- | 'heroic'
17
+ 'heroic',
16
18
  /**
17
19
  * The moralities that roll up to the Villain {@link Alignment} - Villain and Rogue.
18
20
  */
19
- | 'villainous'
21
+ 'villainous',
20
22
  /**
21
23
  * Moralities with access to Paragon City - Hero, Vigilante and Rogue.
22
24
  */
23
- | 'paragon-city-access'
25
+ 'paragon-city-access',
24
26
  /**
25
27
  * Moralities with access to the Rogue Isles - Villain, Rogue and Vigilante.
26
28
  */
27
- | 'rogue-isles-access'
29
+ 'rogue-isles-access',
28
30
  /**
29
31
  * All the moralities.
30
32
  */
31
- | 'all'
33
+ 'all',
34
+ ] as const
35
+ export type Morality = typeof MORALITY[number]
36
+ export type MoralityExtended = typeof MORALITY_EXTENDED[number]
37
+
38
+ /**
39
+ * Maps a morality to the underlying alignment
40
+ */
41
+ export const MoralityMap: Record<Morality | Alignment, Alignment> = {
42
+ hero: 'hero',
43
+ vigilante: 'hero',
44
+ villain: 'villain',
45
+ rogue: 'villain',
46
+ loyalist: 'praetorian',
47
+ resistance: 'praetorian',
48
+ praetorian: 'praetorian',
49
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * The id, or a pair of ids [primal, praetorian] that are used with the /settitle command to set a badge
3
+ */
4
+ export type SetTitleData = [number, number?]
@@ -0,0 +1,11 @@
1
+ import { Sex } from './sex'
2
+ import { Morality } from './morality'
3
+ import { Alignment } from './alignment'
4
+
5
+ /**
6
+ * For badges, aspects like the name, icon, or badge text can vary depending on context, such as the alignment or sex of the character.
7
+ */
8
+ export interface VariantContext {
9
+ readonly morality?: Morality | Alignment
10
+ readonly sex?: Sex
11
+ }
@@ -2,16 +2,16 @@ import { Sex } from './sex'
2
2
  import { Alignment } from './alignment'
3
3
 
4
4
  /**
5
- * Some badge values differ based on the alignment or sex of the character.
5
+ * Some badge values differ based on the alignment or sex of a character.
6
6
  */
7
- export interface AlternateData<V> {
7
+ export interface VariantData<V> {
8
8
  /**
9
- * The character alignment this alternate applies to.
9
+ * The character alignment this variant applies to.
10
10
  */
11
11
  readonly alignment?: Alignment
12
12
 
13
13
  /**
14
- * The character sex this alternate applies to.
14
+ * The character sex this variant applies to.
15
15
  */
16
16
  readonly sex?: Sex
17
17
 
@@ -1,4 +1,8 @@
1
1
  import { Link } from './link'
2
+ import { MoralityExtended } from './morality'
3
+ import { ZoneType } from './zone-type'
4
+ import { MarkdownString } from './markdown-string'
5
+ import { LevelRangeData } from './level-range-data'
2
6
 
3
7
  export interface ZoneData {
4
8
  /**
@@ -13,6 +17,26 @@ export interface ZoneData {
13
17
  */
14
18
  readonly name: string
15
19
 
20
+ /**
21
+ * The type of zone.
22
+ */
23
+ readonly type: ZoneType
24
+
25
+ /**
26
+ * The character moralities that this zone is accessible by.
27
+ */
28
+ readonly morality?: MoralityExtended | MoralityExtended[]
29
+
30
+ /**
31
+ * The level range this zone is recommended for.
32
+ */
33
+ readonly levelRange?: LevelRangeData
34
+
35
+ /**
36
+ * Freeform notes or tips about the zone.
37
+ */
38
+ readonly notes?: MarkdownString
39
+
16
40
  /**
17
41
  * List of external links. Wiki, forums, etc.
18
42
  */
@@ -0,0 +1,59 @@
1
+ export const ZONE_TYPE = [
2
+ /**
3
+ * The standard zone type, even if not technically occurring in the 'City' proper.
4
+ */
5
+ 'city',
6
+ /**
7
+ * An Ouroboros flashback to a zone as it was in a previous era.
8
+ */
9
+ 'echo',
10
+ /**
11
+ * Tutorial zon, usually inaccessible after leaving.
12
+ */
13
+ 'tutorial',
14
+ /**
15
+ * Trial zones, like the Abandoned Sewers trial.
16
+ */
17
+ 'trial',
18
+ /**
19
+ * Hazard zones like the Hollows.
20
+ */
21
+ 'hazard',
22
+ /**
23
+ * Mayhem mission zones.
24
+ */
25
+ 'mayhem',
26
+ /**
27
+ * Safeguard mission zones.
28
+ */
29
+ 'safeguard',
30
+ /**
31
+ * Exists inside a mission not covered by the other types.
32
+ */
33
+ 'mission',
34
+ /**
35
+ * Incarnate trial zones.
36
+ */
37
+ 'incarnate',
38
+ /**
39
+ * Cooprative zones where Heroes and Villains can team up for PvE content.
40
+ */
41
+ 'co-op',
42
+ /**
43
+ * PvP zones like Bloody Bay.
44
+ */
45
+ 'pvp',
46
+ /**
47
+ * Located in an arena PvP map.
48
+ */
49
+ 'arena',
50
+ /**
51
+ * A building, usually contained within another zone, like the AE buildings.
52
+ */
53
+ 'building',
54
+ /**
55
+ * Stuff like the (Phone only) zone.
56
+ */
57
+ 'other',
58
+ ] as const
59
+ export type ZoneType = typeof ZONE_TYPE[number]
@@ -1,35 +1,31 @@
1
1
  type KeysOfType<T, V> = { [P in keyof T]: T[P] extends V ? P : never }[keyof T]
2
2
 
3
3
  export class AbstractIndex<T> {
4
- readonly #keyField: KeysOfType<T, string>
5
4
  protected _values: T[] = []
6
5
  protected _hashTable: Record<string, T> = {}
7
6
 
8
- constructor(keyField: KeysOfType<T, string>) {
9
- this.#keyField = keyField
10
- }
11
-
12
- /**
13
- * Return all indexed values
14
- */
15
- get values(): T[] {
16
- return this._values
17
- }
18
-
19
7
  /**
20
- * Load the given list of values into the index, replacing any existing data.
21
- * @param values List of values.
8
+ * Create a new index.
9
+ * @param keyField The field of the values that will act as the key.
10
+ * @param values Values to index.
22
11
  */
23
- load(values: T[] | undefined): void {
12
+ constructor(keyField: KeysOfType<T, string>, values: T[] | undefined) {
24
13
  this._values = values ?? []
25
14
  this._hashTable = {}
26
15
  for (const value of this.values) {
27
- const key = value[this.#keyField] as string
16
+ const key = value[keyField] as string
28
17
  if (this._hashTable[key] !== undefined) throw new Error(`Duplicate key [${key}]`)
29
18
  this._hashTable[key] = value
30
19
  }
31
20
  }
32
21
 
22
+ /**
23
+ * Return all indexed values
24
+ */
25
+ get values(): T[] {
26
+ return this._values
27
+ }
28
+
33
29
  /**
34
30
  * Get a value from the index
35
31
  * @param key Key string
@@ -1,57 +1,83 @@
1
- import { Badge, compareByDefaultName, compareByZoneKey } from './badge'
1
+ import { Badge, compareByName, compareByReleaseDate, compareByZoneKey } from './badge'
2
2
  import { BadgeSearchOptions } from './badge-search-options'
3
3
  import { Paged } from './paged'
4
4
  import { AbstractIndex } from './abstract-index'
5
5
 
6
6
  export class BadgeIndex extends AbstractIndex<Badge> {
7
- constructor() {
8
- super('key')
7
+ constructor(values: Badge[] | undefined) {
8
+ super('key', values)
9
9
  }
10
10
 
11
11
  search(options?: BadgeSearchOptions): Paged<Badge> {
12
- const filtered = (options?.query || options?.filter)
12
+ const matched = (options?.query || options?.filter)
13
13
  ? this._values.filter(badge => this.#satisfiesQueryPredicate(badge, options?.query) && this.#satisfiesFilterPredicate(badge, options?.filter))
14
14
  : this._values
15
15
 
16
- const totalPages = options?.pageSize ? Math.ceil(filtered.length / (options?.pageSize)) : 1
17
- const page = Math.max(1, Math.min(totalPages, options?.page ?? 1))
18
- const paged = options?.pageSize ? filtered.slice((page - 1) * options.pageSize, page * options?.pageSize) : filtered
16
+ const sorted = this.#sort(matched, options)
19
17
 
20
- const sorted = this.#sort(paged, options?.sort)
18
+ const totalPages = options?.pageSize ? Math.ceil(matched.length / (options?.pageSize)) : 1
19
+ const pageNumber = Math.max(1, Math.min(totalPages, options?.page ?? 1))
20
+ const items = options?.pageSize ? sorted.slice((pageNumber - 1) * options.pageSize, pageNumber * options?.pageSize) : sorted
21
21
 
22
22
  return {
23
- items: sorted,
24
- page: page,
23
+ items: items,
24
+ pageIndex: pageNumber - 1,
25
+ pageNumber: pageNumber,
25
26
  pageSize: options?.pageSize,
26
- totalItems: filtered.length,
27
- totalPages: totalPages,
27
+ matchedItemCount: matched.length,
28
+ totalItemCount: this._values.length,
29
+ totalPageCount: totalPages,
28
30
  }
29
31
  }
30
32
 
31
33
  #satisfiesQueryPredicate(badge: Badge, query?: BadgeSearchOptions['query']): boolean {
32
34
  const queryString = query?.str?.toLowerCase() ?? ''
33
- return !!(((query?.on?.name ?? true) && badge.name.canonical.some(x => x.value.toLowerCase().includes(queryString)))
34
- || (query?.on?.badgeText && badge.badgeText.canonical.some(x => x.value.toLowerCase().includes(queryString)))
35
- || (query?.on?.acquisition && badge.acquisition?.toLowerCase().includes(queryString))
36
- || (query?.on?.effect && badge.effect?.toLowerCase().includes(queryString))
37
- || (query?.on?.notes && badge.notes?.toLowerCase().includes(queryString))
38
- || (query?.on?.setTitle && (badge.setTitleId?.some(x => x?.toString().includes(queryString)))))
35
+ const fields = query?.fields ? new Set(query?.fields) : new Set(['name']) // Default to name if not provided
36
+ if (fields.size === 0) return true
37
+
38
+ return !!((fields.has('name') && badge.name.canonical.some(x => x.value.toLowerCase().includes(queryString)))
39
+ || (fields.has('badge-text') && badge.badgeText.canonical.some(x => x.value.toLowerCase().includes(queryString)))
40
+ || (fields.has('acquisition') && badge.acquisition?.toLowerCase().includes(queryString))
41
+ || (fields.has('effect') && badge.effect?.toLowerCase().includes(queryString))
42
+ || (fields.has('notes') && badge.notes?.toLowerCase().includes(queryString))
43
+ || (fields.has('set-title-id') && (badge.setTitleId?.primal.toString() === queryString))
44
+ || (fields.has('set-title-id') && (badge.setTitleId?.praetorian?.toString() === queryString))
45
+ )
39
46
  }
40
47
 
41
48
  #satisfiesFilterPredicate(badge: Badge, filter?: BadgeSearchOptions['filter']): boolean {
42
49
  return (!filter?.type || badge.type === filter.type)
43
50
  && (!filter?.zoneKey || badge.zoneKey === filter.zoneKey)
44
51
  && (!filter?.morality || badge.morality.has(filter.morality))
52
+ && (!filter?.predicate || filter.predicate(badge))
45
53
  }
46
54
 
47
- #sort(badges: Badge[], sort?: BadgeSearchOptions['sort']): Badge[] {
48
- if (!sort) return badges
49
- const ascending = sort.dir !== 'desc'
50
-
51
- if (sort.by === 'badge-name') return badges.sort((a, b) => ascending ? compareByDefaultName(a, b) : compareByDefaultName(b, a))
52
-
53
- if (sort.by === 'zone-key') return badges.sort((a, b) => ascending ? compareByZoneKey(a, b) : compareByZoneKey(b, a))
54
-
55
- return sort.dir === 'desc' ? badges.reverse() : badges
55
+ #sort(badges: Badge[], options?: BadgeSearchOptions): Badge[] {
56
+ switch (options?.sort) {
57
+ case 'name.asc': {
58
+ return badges.toSorted((a, b) => compareByName(a, b, options.context))
59
+ }
60
+ case 'name.desc': {
61
+ return badges.toSorted((a, b) => compareByName(b, a, options.context))
62
+ }
63
+ case 'zone-key.asc': {
64
+ return badges.toSorted(compareByZoneKey)
65
+ }
66
+ case 'zone-key.desc': {
67
+ return badges.toSorted((a, b) => compareByZoneKey(b, a))
68
+ }
69
+ case 'release-date.asc': {
70
+ return badges.toSorted(compareByReleaseDate)
71
+ }
72
+ case 'release-date.desc': {
73
+ return badges.toSorted((a, b) => compareByReleaseDate(b, a))
74
+ }
75
+ case 'canonical.desc': {
76
+ return badges.toReversed()
77
+ }
78
+ default: {
79
+ return [...badges]
80
+ }
81
+ }
56
82
  }
57
83
  }
@@ -5,7 +5,7 @@ import { Key } from './key'
5
5
  import { MarkdownString } from '../api/markdown-string'
6
6
  import { Link } from '../api/link'
7
7
  import { Location } from './location'
8
- import { coalesceToArray } from '../util'
8
+ import { coalesceToArray } from '../util/coalesce-to-array'
9
9
 
10
10
  export class BadgeRequirement {
11
11
  /**
@@ -1,5 +1,10 @@
1
1
  import { BadgeType } from '../api/badge-type'
2
- import { MoralityExtended } from '../api/morality'
2
+ import { Morality } from '../api/morality'
3
+ import { Badge } from './badge'
4
+ import { VariantContext } from '../api/variant-context'
5
+
6
+ export type BadgeQueryableField = 'name' | 'badge-text' | 'acquisition' | 'notes' | 'effect' | 'set-title-id'
7
+ export type BadgeSort = `${'canonical' | 'name' | 'zone-key' | 'release-date'}.${'asc' | 'desc'}`
3
8
 
4
9
  export interface BadgeSearchOptions {
5
10
 
@@ -10,14 +15,7 @@ export interface BadgeSearchOptions {
10
15
  */
11
16
  query?: {
12
17
  str?: string
13
- on?: {
14
- name?: boolean
15
- badgeText?: boolean
16
- acquisition?: boolean
17
- notes?: boolean
18
- effect?: boolean
19
- setTitle?: boolean
20
- }
18
+ fields?: BadgeQueryableField[]
21
19
  }
22
20
 
23
21
  /**
@@ -26,18 +24,21 @@ export interface BadgeSearchOptions {
26
24
  filter?: {
27
25
  type?: BadgeType
28
26
  zoneKey?: string
29
- morality?: MoralityExtended
27
+ morality?: Morality
28
+ predicate?: (badge: Badge) => boolean
30
29
  }
31
30
 
31
+ /**
32
+ * Adjust search results based on a given variant context (morality or sex of a character).
33
+ */
34
+ context?: VariantContext
35
+
32
36
  /**
33
37
  * Sort results.
34
38
  *
35
39
  * Badges are assumed to be in canonical order in the content bundle, and should match the in-game display order.
36
40
  */
37
- sort?: {
38
- by?: 'canonical' | 'badge-name' | 'zone-key'
39
- dir?: 'asc' | 'desc'
40
- }
41
+ sort?: BadgeSort
41
42
 
42
43
  /**
43
44
  * The page (1-based)
@@ -3,13 +3,17 @@ import { Link } from '../api/link'
3
3
  import { BadgeData } from '../api/badge-data'
4
4
  import { BadgeRequirement } from './badge-requirement'
5
5
  import { Key } from './key'
6
- import { Alternates } from './alternates'
6
+ import { Variants } from './variants'
7
7
  import { MarkdownString } from '../api/markdown-string'
8
- import { coalesceToArray } from '../util'
9
8
  import { MoralityList } from './morality-list'
9
+ import { AbstractIndex } from './abstract-index'
10
+ import { toDate } from '../util/to-date'
11
+ import { coalesceToArray } from '../util/coalesce-to-array'
12
+ import { SetTitleIds } from './set-title-ids'
13
+ import { VariantContext } from '../api/variant-context'
10
14
 
11
15
  export class Badge {
12
- readonly #requirementsIndex: Record<string, BadgeRequirement> = {}
16
+ readonly #requirementsIndex: AbstractIndex<BadgeRequirement>
13
17
  readonly #zoneKeys = new Set<string>()
14
18
 
15
19
  /**
@@ -27,7 +31,12 @@ export class Badge {
27
31
  *
28
32
  * May vary by character sex or alignment.
29
33
  */
30
- readonly name: Alternates<string>
34
+ readonly name: Variants<string>
35
+
36
+ /**
37
+ * The date that the badge was added to the game.
38
+ */
39
+ readonly releaseDate: Date
31
40
 
32
41
  /**
33
42
  * The character moralities that this badge is available to.
@@ -37,7 +46,7 @@ export class Badge {
37
46
  /**
38
47
  * The badge text as it appears in-game. May vary by character sex or alignment.
39
48
  */
40
- readonly badgeText: Alternates<MarkdownString>
49
+ readonly badgeText: Variants<MarkdownString>
41
50
 
42
51
  /**
43
52
  * Short description of how to acquire the badge. Detailed instructions will be in the notes field.
@@ -49,7 +58,7 @@ export class Badge {
49
58
  *
50
59
  * May vary by character sex or alignment.
51
60
  */
52
- readonly icon: Alternates<string>
61
+ readonly icon: Variants<string>
53
62
 
54
63
  /**
55
64
  * Freeform notes or tips about the badge.
@@ -65,18 +74,13 @@ export class Badge {
65
74
  * The id used with the in-game `/settitle` command to apply the badge.
66
75
  * The first value is the id for primal characters and the (optional) second number is the id for praetorian characters.
67
76
  */
68
- readonly setTitleId?: [number, number?]
77
+ readonly setTitleId?: SetTitleIds
69
78
 
70
79
  /**
71
80
  * A description of the effect the badge will have, such as a buff or granting a temporary power.
72
81
  */
73
82
  readonly effect?: MarkdownString
74
83
 
75
- /**
76
- * Represents the requirements for badges with multiple fulfillment steps, such as visiting monuments for history badges, completing missions, or collecting other badges.
77
- */
78
- readonly requirements?: BadgeRequirement[]
79
-
80
84
  /**
81
85
  * Some badges are not included in the badge total count... such as Flames of Prometheus, which can be removed by redeeming it for a Notice of the Well.
82
86
  */
@@ -85,32 +89,36 @@ export class Badge {
85
89
  constructor(badgeData: BadgeData) {
86
90
  this.key = new Key(badgeData.key).value
87
91
  this.type = badgeData.type
88
- this.name = new Alternates(badgeData.name)
92
+ this.name = new Variants(badgeData.name)
93
+ this.releaseDate = toDate(badgeData.releaseDate)
89
94
  this.morality = new MoralityList(coalesceToArray(badgeData.morality))
90
- this.badgeText = new Alternates(badgeData.badgeText ?? [])
95
+ this.badgeText = new Variants(badgeData.badgeText ?? [])
91
96
  this.acquisition = badgeData.acquisition
92
- this.icon = new Alternates(badgeData.icon ?? [])
97
+ this.icon = new Variants(badgeData.icon ?? [])
93
98
  this.notes = badgeData.notes
94
99
  this.links = badgeData.links ?? []
95
100
  this.effect = badgeData.effect
96
- this.setTitleId = badgeData.setTitleId
101
+ this.setTitleId = badgeData.setTitleId ? new SetTitleIds(badgeData.setTitleId) : undefined
97
102
  this.ignoreInTotals = badgeData.ignoreInTotals ?? false
98
103
 
99
- this.requirements = badgeData.requirements?.map((requirementData) => {
100
- if (this.#requirementsIndex[requirementData.key]) throw new Error(`Duplicate badge requirement key [${badgeData.key}:${requirementData.key}]`)
101
- const requirement = new BadgeRequirement(requirementData)
102
- this.#requirementsIndex[requirement.key] = requirement
104
+ this.#requirementsIndex = new AbstractIndex<BadgeRequirement>('key', badgeData.requirements?.map(x => new BadgeRequirement(x)))
105
+
106
+ for (const requirement of this.#requirementsIndex.values) {
103
107
  if (requirement.location) for (const location of requirement.location) {
104
108
  if (location.zoneKey) this.#zoneKeys.add(location.zoneKey)
105
109
  }
106
- return requirement
107
- })
110
+ }
108
111
  }
109
112
 
110
- getRequirement(key: string): BadgeRequirement {
111
- const result = this.#requirementsIndex[key]
112
- if (result === undefined) throw new Error(`Unknown badge requirement key [${key}]`)
113
- return result
113
+ /**
114
+ * Represents the requirements for badges with multiple fulfillment steps, such as visiting monuments for history badges, completing missions, or collecting other badges.
115
+ */
116
+ get requirements(): BadgeRequirement[] {
117
+ return this.#requirementsIndex.values
118
+ }
119
+
120
+ getRequirement(key: string): BadgeRequirement | undefined {
121
+ return this.#requirementsIndex.get(key)
114
122
  }
115
123
 
116
124
  /**
@@ -128,9 +136,9 @@ export class Badge {
128
136
  }
129
137
  }
130
138
 
131
- export function compareByDefaultName(a?: Badge, b?: Badge): number {
132
- const aName = a?.name.default?.value
133
- const bName = b?.name.default?.value
139
+ export function compareByName(a?: Badge, b?: Badge, context?: VariantContext): number {
140
+ const aName = a?.name?.getValue(context)
141
+ const bName = b?.name?.getValue(context)
134
142
  if (!aName && !bName) return 0
135
143
  if (!aName) return 1
136
144
  if (!bName) return -1
@@ -145,3 +153,12 @@ export function compareByZoneKey(a?: Badge, b?: Badge): number {
145
153
  if (!bZone) return -1
146
154
  return aZone.localeCompare(bZone)
147
155
  }
156
+
157
+ export function compareByReleaseDate(a?: Badge, b?: Badge): number {
158
+ const aReleaseDate = a?.releaseDate?.getTime()
159
+ const bReleaseDate = b?.releaseDate?.getTime()
160
+ if (aReleaseDate === bReleaseDate) return 0
161
+ if (!aReleaseDate) return 1
162
+ if (!bReleaseDate) return -1
163
+ return aReleaseDate < bReleaseDate ? -1 : 1
164
+ }
@@ -1,12 +1,23 @@
1
1
  import { Link } from '../api/link'
2
2
  import { MarkdownString } from '../api/markdown-string'
3
3
  import { BundleHeaderData } from '../api/bundle-header-data'
4
+ import { toDate } from '../util/to-date'
4
5
 
5
6
  export class BundleHeader {
6
7
  /**
7
- * Name of the content bundle.
8
+ * Name of the fork this bundle contains data for.
8
9
  */
9
- readonly name?: string
10
+ readonly name: string
11
+
12
+ /**
13
+ * Version number for this data package.
14
+ */
15
+ readonly version: string
16
+
17
+ /**
18
+ * The time this bundle was last updated.
19
+ */
20
+ readonly lastUpdateTime: Date
10
21
 
11
22
  /**
12
23
  * Description of the fork.
@@ -28,17 +39,14 @@ export class BundleHeader {
28
39
  */
29
40
  readonly links?: Link[]
30
41
 
31
- /**
32
- * The current version of the data package.
33
- */
34
- readonly version?: string
35
-
36
- constructor(data: BundleHeaderData | undefined) {
37
- this.name = data?.name
42
+ constructor(data: BundleHeaderData) {
43
+ if (!data) throw new Error('Missing header data')
44
+ this.name = data.name
45
+ this.version = data.version
46
+ this.lastUpdateTime = toDate(data.lastUpdateTime)
38
47
  this.description = data?.description
39
48
  this.repositoryUrl = data?.repositoryUrl
40
49
  this.changelogUrl = data?.changelogUrl
41
50
  this.links = data?.links ?? []
42
- this.version = data?.version
43
51
  }
44
52
  }