From d8b539bce121b280e534f732dba606fc39da1eaa Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 17 Aug 2026 17:21:10 +0200 Subject: [PATCH] feat(p5x): track Persona 5: The Phantom X MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the game and its Game8 events page, the second of the games named in the release thread. `parseAdjacentFullRange` reads the shape this page uses: two full dates divided by an `
` rather than a dash, so a tag-stripping reader sees only whitespace between them. It is anchored at both ends and needs a year on each half, because without that "August 12, 2026 Day 3 rewards" reads "Day 3" as an end. Three of the four live rows publish. "Take Your Heart" ends 30 days after each player makes an account, so it has no calendar date and no honest place on a calendar. "Login Campaigns" names two candidate ends ("July 16/30, 2026") — it keeps its real start and takes no end, and the leftover is not shown as a summary either, since a date the parser refused to trust must not reappear dressed as information. The page lists each live event twice: once in a bare Event|Duration table and again under its own heading with Start Date / End Date rows and a paragraph of prose. The second copy corroborates the dates, so deduping now fills a missing summary from the copy it drops. Dates are still taken wholesale from the better-dated copy and never blended. Verified by re-extracting the page independently of the parser: 4 rows, 3 events, the fourth correctly skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../p5x/game8-events-2026-08-17.expected.json | 65 + fixtures/p5x/game8-events-2026-08-17.html | 1087 +++++++++++++++++ src/ingest/adapters/index.ts | 6 + src/ingest/dates.ts | 31 + src/ingest/parsers/game8.ts | 48 +- src/shared/games.ts | 4 + src/shared/schema.ts | 1 + test/adapters/game8.test.ts | 54 + test/dates.test.ts | 34 + 9 files changed, 1324 insertions(+), 6 deletions(-) create mode 100644 fixtures/p5x/game8-events-2026-08-17.expected.json create mode 100644 fixtures/p5x/game8-events-2026-08-17.html diff --git a/fixtures/p5x/game8-events-2026-08-17.expected.json b/fixtures/p5x/game8-events-2026-08-17.expected.json new file mode 100644 index 0000000..0e302b1 --- /dev/null +++ b/fixtures/p5x/game8-events-2026-08-17.expected.json @@ -0,0 +1,65 @@ +[ + { + "id": "p5x:1st-anniversary-celebration:2026-06-25", + "game": "p5x", + "title": "1st Anniversary Celebration", + "type": "other", + "summary": "June 26, 2026 marks the first anniversary for the Global and Japanese versions of Persona 5: The Phantom X!", + "startsAt": "2026-06-25T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-07-30T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/532244", + "sourceId": "p5x-game8-events", + "status": "published", + "confidence": 0.85, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "p5x:login-campaigns:2026-06-25", + "game": "p5x", + "title": "Login Campaigns", + "type": "login", + "summary": null, + "startsAt": "2026-06-25T00:00:00.000Z", + "startPrecision": "day", + "endsAt": null, + "endPrecision": "unknown", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/532244", + "sourceId": "p5x-game8-events", + "status": "published", + "confidence": 0.75, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + }, + { + "id": "p5x:haunted-beach-shack-summer-event-part-2:2026-07-30", + "game": "p5x", + "title": "Haunted Beach Shack Summer Event (Part 2)", + "type": "other", + "summary": "The Haunted Beach Shack Summer Event Part 2 went live on July 30, 2026 during the Version 4.6 update . Players will be given until August 13, 2026, to progress through the event and claim all of its rewards. Activities in the event include a Sea Fishing Cruise and a Boss Battle against Sahimochi-no-Kami .", + "startsAt": "2026-07-30T00:00:00.000Z", + "startPrecision": "day", + "endsAt": "2026-08-13T00:00:00.000Z", + "endPrecision": "day", + "regionScoped": false, + "regionEnds": null, + "sourceUrl": "https://game8.co/games/Persona-5-Phantom-X/archives/532244", + "sourceId": "p5x-game8-events", + "status": "published", + "confidence": 0.85, + "extractionMethod": "parser", + "version": 1, + "firstSeenAt": "2026-08-14T00:00:00.000Z", + "updatedAt": "2026-08-14T00:00:00.000Z" + } +] diff --git a/fixtures/p5x/game8-events-2026-08-17.html b/fixtures/p5x/game8-events-2026-08-17.html new file mode 100644 index 0000000..3f31900 --- /dev/null +++ b/fixtures/p5x/game8-events-2026-08-17.html @@ -0,0 +1,1087 @@ +List of All Events | Persona 5: The Phantom X (P5X)|Game8 + + + + + + + + +

What can you do as a free member?

Member benefits illustration

Create your free account today and unlock all our premium features and tools to enhance your gaming experience.

Member benefits illustration

Create your free account today and save articles to your watchlist and get notified when they're updated with new information.

Member benefits illustration

Create your free account today and save your favorite games for quick access later, synced across all your devices.

Member benefits illustration

By creating a Game8 account and logging in, you'll receive instant notifications when someone replies to your posts.

Comment rating feature illustration

By creating a Game8 account and logging in, you can make use of convenient features in the comments section, such as rating and sorting comments.

Premium archive feature illustration

By creating a Game8 account and logging in, you can access Premium articles that are exclusively available to members.

Map tool feature illustration

By creating a Game8 account and logging in, the items you checked on the map tool will be saved.

Site Interface

Guest
Free Member
Article Watchlist
Game Bookmarks
Cross-device Sync
Light/Dark Theme Toggle
User Profiles
Direct Feedback
Comment Rating
Comment Notification
Premium Articles

Game Tools

Guest
Free Member
Interactive Map Access
Interactive Map Pins
Interactive Map Comments
Interactive Map Pins Cross-Device
Check List
Event Choice Checker
Deck Builder Cross-Device
Message Board Notification
Message Board Cross-Device
Build Planner
Stat Calculator
Diagnostic Tool
Weapon/Armor Wishlist
Team Builder
Character Scoring
Tier List Maker

Want more information?Learn more

+
Persona 5: The Phantom X (P5X)

List of All Events

+ + +Add to Watchlist
+

Version 4.7 and 4.8 Patch Notes
┗ New Character: Berry | Ichigo Shikano Synergy
┗ New Personas: Vasuki | Trumpeter
★ Upcoming Character: Blitz
☆ Tier Lists: Characters | Personas | Rerolls
★ Boards: Companio | Friends | Toy Trading

+ +
Persona 5 X List of All Events
+ +

Here are all the available events for Persona 5: The Phantom X (P5X) as of August 2026. See all the available events and learn more about each event here!

+ + +

List of All Events

Events Calendar

+
+

All Available Events

+ + + + + + + + + + + + + + + + + + + + + +
EventDuration
Haunted Beach Shack Summer Event (Part 2)July 30, 2026
August 13, 2026
1st Anniversary CelebrationJune 25, 2026
July 30, 2026
Login CampaignsJune 25, 2026
July 16/30, 2026
Take Your HeartEnds after 30 days of making your account.
+ +

These are the active events in the game as of Version 4.5. Take Your Heart is different from the other events, as the countdown will start when you make your account and end after 30 days after that.

+ +

Version 4.5 Patch Notes and Summary

+ +

Finished Events

Event Duration and Dates
Haunted Beach Shack Summer Event (Part 1) July 16, 2026
July 30, 2026
Miracle Concert Collaboration Event June 25, 2026
July 15, 2026
Dancing with the Happy-Go-Lucky Lions March 5, 2026
March 19, 2026
Fireworks Festival March 5, 2026
March 19, 2026
Lunar New Year Greetings March 5, 2026
March 19, 2026
Lunar New Year Quiz Rally March 12, 2026
March 19, 2026
Login Bonus March 5, 2026
April 2, 2026
Unconditonal Love Final Phase February 05, 2026
March 18, 2026
Tabby's Treasure Hunt February 26, 2026
March 04, 2026
Cycle of Fate February 26, 2026
March 04, 2026
Take Your Love Event February 13, 2026
March 04, 2026
Puzzle February 13, 2026
March 04, 2026
Daily Login Bonus - Platinum Ticket February 13, 2026
March 04, 2026
Tycoon Season 3 February 05, 2026
February 17, 2026
Daily Login Bonus - Platinum Milicoin February 05, 2026
February 17, 2026
Tycoon Season 2 December 25, 2025
February 4, 2026
Half-Year Anniversary December 25, 2025
February 4, 2026
Akihabara Mystery Map February 22, 2026
February 4, 2026
Robo Challenge February 22, 2026
February 4, 2026
New Daily Hoot-n-Loot Until January 31, 2026
New Year's Gifts December 25, 2025
January 14, 2026
Cycle of Fate December 29, 2025
January 08, 2026
Crossroads of Fate Twins Arc December 25, 2025
January 21, 2026
Twins Capsule Toys December 25, 2025
January 21, 2026
New Year's Blessing December 25, 2025
January 15, 2026
Tabby's Treasure Hunt Event December 14, 2025
December 24, 2025
Crossroads of Fate: Spaceport Arc Event December 6, 2025
December 25, 2025
Spin to Win Raffle Rerun December 6, 2025
December 25, 2025
Wish Line for Metro of Desire December 6, 2025
December 25, 2025
Tycoon Season 1 November 25, 2025
December 25, 2025
Unconditonal Love Phase 2 October 23, 2025
December 8, 2025
Trial of Thorns Phase 2 December 6, 2025
Gift From the Disciplinary Committee Chair December 6, 2025
Crossroads of Fate Pyramid Arc November 25, 2025
Summer! Swimsuits! Sea Time! October 9, 2025
Seaside Vacation Part Two October 23, 2025
Fireworks Festival October 23, 2025
Unconditional Love Phase 1 October 16, 2025
Seaside Vacation October 9, 2025
Puzzle Event September 11, 2025
Trial of Thorns September 4, 2025
Spin to Win Raffle September 4, 2025
Metro of Desire Hope Line September 4, 2025
Tabby's Treasure Hunt Event August 21, 2025
Crossroads of Fate Museum and Bank Arcs August 21, 2025
Relativity's Labyrinth August 21, 2025
Realm of Repression Rush Event July 9, 2025
Realm of Repression Awakening Avenue July 23, 2025
Spin to Win Raffle Event July 24, 2025
Phantom Idol Trial (Joker) July 24, 2025
New Contracts (Yui) July 24, 2025
Trials of the Deep N/A
Catch Your Eye N/A

+

Current Events Info

Haunted Beach Shack Summer Event Part 2

+ + + + + + + + + + + + + + + + +
Start DateJuly 30, 2026
End DateAugust 13, 2026
Rewards +・ Beachwear
+・Haunted Beach Shack
+・Summer Chigusa: Great Success
+
P5 Phantom X - Everlasting Incense IconEverlasting Incense
+
Persona 5X - Vicissitude InkVicissitude Ink
+
Gold Ticket iconGold Ticket
+
Kagura BellKagura Bell
+・ Unknown Orange Revelation S (Revelation)
+・Money +
+ +

The Haunted Beach Shack Summer Event Part 2 went live on July 30, 2026 during the Version 4.6 update. Players will be given until August 13, 2026, to progress through the event and claim all of its rewards. Activities in the event include a Sea Fishing Cruise and a Boss Battle against Sahimochi-no-Kami.

+ +

Haunted Beach Shack Part 2 Event Guide

+

1st Anniversary Celebration

+ + + + + + + + + + + + + + + + +
P5 Phantom X - 1st Anniversary Celebration
Start DateJune 25, 2026
End DateJuly 30, 2026
RewardsMeta Jewels, Platinum Milicon, Joy Medal, Everlasting Incense, Vicissitude Ink, Resonating Heart Badge, Miracle Concert 5 Badge
+ +

June 26, 2026 marks the first anniversary for the Global and Japanese versions of Persona 5: The Phantom X!

+ +

In celebration of reaching this milestone, players are set to receive multiple rewards through various minigames and activites. This lasts for over a month, so make sure to take the time to collect all the rewards and freebies!

+ +

1st Anniversary Campaign and Event

+

Log-in Campaigns

+ + + + + + + + + +
Collaboration Event1st Anniversary
+ +

Both the Collaboration event featuring Hatsune Miku, and the 1st Anniversary events, feature log-in campaigns that lasts for over 12 days.

+ +

Each campaign will feature a total of 10 Platinum Tickets and 5 Platinum Milicoins, and they will be running simultaneously! Make sure to check in on them to receive a grand total of 20 Platinum Tickets and 10 Platinum Milicoins!

+ +

Daily Login Bonus Event

+

Take Your Heart Event

+
Take Your Heart Event
+ +

The Take Your Heart Newcomer Event has 5 different sub-events under it — the Thieves' Alliance, Novice Thief Support (Banner), Converging Destinies, Crossroads of Fate, Thieves' Exchange, and Cafe Leblanc. These sub-events will have different rewards and ways to do them, so be sure to check out each one!

+ +

This event is only available for a month after starting your journey in P5X, so try to progress on this and get all the rewards before it disappears.
Take Your Heart Newcomer Event

+

Persona 5: The Phantom X Related Guides

+
Persona 5 X Main Links Partial
+ +

Persona 5: The Phantom X Wiki

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Persona 5: The Phantom X Wiki Contents
P5 Phantom X - Character Partial BannerCharactersPersona 5 X Personas Partial BannerPersonas
Persona 5 X - BannersBannersPersona 5 X - Tips and TricksTips and Tricks
Persona 5 X - Weapons PartialWeaponsPersona 5 X - Items PartialItems
Persona 5 X - WalkthroughsWalkthroughsP5 Phantom X - BossesBosses
Synergy Bonds.pngSynergy BondsPersona 5 X - Palaces  Partial BannerPalaces
Persona 5 X - Revelation CardsRevelation CardsSkills PartialSkills
Persona 5 X Outfits PartialOutfitsPersona 5 X - EventsEvents
Persona 5 X - NewsNewsAward.pngAwards
+

Recommended Guides

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tier Lists
Best Characters Tier ListBest Characters Tier ListBest 4-Star Characters4-Star Character Tier List
Reroll Tier ListReroll Tier ListPersona Tier ListPersona Tier List
Questions and Answers
Classroom Questions and AnswersClassroom Questions and AnswersPart Time Questions and Answers LocationsPart Time Work Questions and Answers
List of Guides
How to RerollHow to RerollHow to Redeem CodeList of Codes: How to Redeem Codes
Currency Guide How to Get Free CrystalsCurrency Guide: How to Get Free CrystalsBest Packs to Buy TextlessBest Packs: What to Buy in the Shop
Best Banners to Spend On TextlessBest Banners to Pull OnP5X - Best Starting 5-Star Textless Banner Best Starting 5 Star: Who To Pick?
Persona 5 The Phantom X - Best Team Compositions no textBest Team CompositionsPersona 5 Phantom X - Progression GuideEarly Progression Guide: Best Start
Persona 5 The Phantom X - Best Synergies to Max Best Synergies to MaxPersona 5 X Best CognititeBest Cognitite to Get
P5 Phantom X - Best Weapons in Selection BoxesBest Weapons in Selection BoxesP5 Phantom X - Tartarus Game Mode Top Image.pngTartarus Game Mode
+

Message Boards

+ + + + + + + + + +
P5X Companio RecruitmentCompanio Recruitment BoardP5X Friend RequestFriend Request Board
Persona 5 X Gacha Trading Board,sizeTrading Board-
+

Author

    +

    All rights reserved

    + + +
    Guide Index
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index 773a91c..82cfe4c 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -72,6 +72,12 @@ const SOURCES: SourceSpec[] = [ url: "https://game8.co/games/Infinity-Nikki/archives/487445", parserId: "game8", }, + { + id: "p5x-game8-events", + game: "p5x", + url: "https://game8.co/games/Persona-5-Phantom-X/archives/532244", + parserId: "game8", + }, ]; function toAdapter(spec: SourceSpec): Adapter { diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index e17caa3..760d51c 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -147,6 +147,37 @@ export function parseShortSlashRange( }; } +/** + * "July 30, 2026 August 13, 2026" → both instants. + * + * Game8 separates the two halves of a duration cell with `
    ` rather than a + * dash on some templates, and a tag-stripping reader sees only whitespace + * between them. Both anchors and both years are required: without the anchors + * this would happily read "August 12, 2026 Day 3 rewards" as a range, and a + * bare "Month D" second half could belong to any year. + */ +export function parseAdjacentFullRange( + input: string, +): { start: ParsedInstant; end: ParsedInstant } | null { + const re = + /^\s*([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})\s+([A-Za-z]+)\.?\s+(\d{1,2}),\s*(\d{4})\s*$/; + const m = re.exec(input); + if (!m) return null; + + const startMonth = monthNumber(m[1] ?? ""); + const endMonth = monthNumber(m[4] ?? ""); + if (startMonth === null || endMonth === null) return null; + + const startIso = iso(Number(m[3]), startMonth, Number(m[2])); + const endIso = iso(Number(m[6]), endMonth, Number(m[5])); + if (startIso === null || endIso === null) return null; + + return { + start: { iso: startIso, precision: "day" }, + end: { iso: endIso, precision: "day" }, + }; +} + /** * "Start: January 24, 2025 End: Permanent" → the start, and whatever the end * half turns out to be. diff --git a/src/ingest/parsers/game8.ts b/src/ingest/parsers/game8.ts index 8b5b997..cd80b44 100644 --- a/src/ingest/parsers/game8.ts +++ b/src/ingest/parsers/game8.ts @@ -4,6 +4,7 @@ import { type GachaEvent, } from "../../shared/schema.ts"; import { + parseAdjacentFullRange, parseFullRange, parseLabelledStartEnd, parseMonthDayRange, @@ -41,7 +42,8 @@ const INCLUDED_SECTIONS = [ /recurring events/i, /events? schedule/i, /featured events/i, - /list of events/i, + /list of (all )?events/i, + /all available events/i, /ongoing events/i, ]; @@ -57,9 +59,15 @@ const EXCLUDED_SECTIONS = [ /finished events/i, ]; -/** Label/value rows carrying a single boundary instant. */ -const START_LABEL = /^(event|test run|banner)\s+start$/i; -const END_LABEL = /^(event|test run|banner)\s+end$/i; +/** + * Label/value rows carrying a single boundary instant. + * + * The qualifier is optional because Persona 5 labels these `Start Date` / + * `End Date` where Genshin writes `Event Start`. Anchored at both ends, so a + * cell that merely mentions a start is not mistaken for one. + */ +const START_LABEL = /^(event|test run|banner)?\s*start(\s+date)?$/i; +const END_LABEL = /^(event|test run|banner)?\s*end(\s+date)?$/i; /** Label/value rows carrying a whole range in one cell. */ const RANGE_LABEL = /^(availability period|event period|duration|period|dates)$/i; @@ -284,6 +292,13 @@ function readStartEndTable(headers: string[], rows: string[][]): Candidate[] { const ONE_DATE = String.raw`(?:\d{1,2}/\d{1,2}/\d{2,4}|[A-Za-z]+\.?\s+\d{1,2}(?:,\s*\d{4})?)`; +/** + * A date that carries its own year. Required of the second half when nothing + * but whitespace separates the two \u2014 "August 12, 2026 Day 3 rewards" would + * otherwise read "Day 3" as the end and eat the description. + */ +const DATED_YEAR = String.raw`(?:\d{1,2}/\d{1,2}/\d{2,4}|[A-Za-z]+\.?\s+\d{1,2},\s*\d{4})`; + /** * A leading range, whose end may be a date or a stated non-date such as * "Permanent" or "End of 4.6". Those words are listed rather than matched @@ -294,7 +309,9 @@ const RANGE_PREFIX = new RegExp( ONE_DATE + String.raw`(?:\s*[-\u2013\u2014]\s*(?:` + ONE_DATE + - String.raw`|permanent|tbd|ongoing|end of [\d.]+))?\s*`, + String.raw`|permanent|tbd|ongoing|end of [\d.]+)|\s+` + + DATED_YEAR + + String.raw`)?\s*`, "i", ); @@ -325,6 +342,12 @@ function proseAfterDates(cell: string): string | null { .trim(); // Too short to be a description — probably leftover punctuation. if (rest.length < 12) return null; + // What is left still looks like a date, so the cell was a range shape we only + // partly understood ("June 25, 2026 July 16/30, 2026" — two candidate ends, + // which is why no end was taken). Showing the leftover as the blurb would + // present a date the parser deliberately refused to trust as if it were + // information about the event. + if (/^(?:\d{1,2}[/.]|[A-Za-z]+\.?\s+\d)/.test(rest)) return null; return isRequirementOnly(rest) ? null : rest; } @@ -337,6 +360,7 @@ function parseRange( parseShortSlashRange(value) ?? parseMonthDayRange(value) ?? parseLabelledStartEnd(value) ?? + parseAdjacentFullRange(value) ?? parseOpenRange(value) ); } @@ -400,7 +424,19 @@ function dedupe(events: GachaEvent[]): GachaEvent[] { for (const e of events) { const existing = byId.get(e.id); if (existing === undefined || e.confidence > existing.confidence) { - byId.set(e.id, e); + // The loser can still carry a blurb the winner lacks: Persona 5 lists an + // event in a bare `Event | Duration` table and again under its own + // heading with a paragraph of prose. Dates are taken wholesale from the + // better-dated copy and never blended — only a *missing* summary is + // filled, so this can add information but never contradict any. + byId.set( + e.id, + e.summary === null && existing?.summary != null + ? { ...e, summary: existing.summary } + : e, + ); + } else if (existing.summary === null && e.summary !== null) { + byId.set(e.id, { ...existing, summary: e.summary }); } } return [...byId.values()].sort((a, b) => diff --git a/src/shared/games.ts b/src/shared/games.ts index 46eebb3..c0b6464 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -54,6 +54,10 @@ export const GAMES: Record = { endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } }, nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" }, nikki: { id: "nikki", name: "Infinity Nikki", short: "Nikki", hue: "#F27BB0" , studio: "Infold Games", dailyTasks: "Daily tasks, Vital Energy" }, + // No `resetOffsets`: nothing in the source states a server map that differs + // from the regional default, and an offset invented here would move real + // readers' day keys. Add one only against evidence — see games.ts § resetOffsets. + p5x: { id: "p5x", name: "Persona 5: The Phantom X", short: "P5X", hue: "#D62246" , studio: "Perfect World", dailyTasks: "Daily missions, stamina" }, }; export const GAME_LIST: GameMeta[] = Object.values(GAMES); diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 696ea81..aafb8d9 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -9,6 +9,7 @@ export const GameId = z.enum([ "endfield", "nte", // Neverness to Everness "nikki", // Infinity Nikki + "p5x", // Persona 5: The Phantom X ]); export type GameId = z.infer; diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index f8af79f..7e3cf6c 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -28,6 +28,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [ { adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" }, { adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" }, { adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" }, + { adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" }, ]; async function runAdapter(adapter: Adapter, fixture: string) { @@ -258,6 +259,59 @@ describe("nikki", () => { }); }); +describe("p5x", () => { + const fixture = "fixtures/p5x/game8-events-2026-08-17"; + + test("reads a range whose halves are separated by an
    ", async () => { + const events = await runAdapter(adapter("p5x-game8-events"), fixture); + const beach = events.find((e) => + e.title.startsWith("Haunted Beach Shack Summer Event"), + ); + expect(beach?.startsAt).toBe("2026-07-30T00:00:00.000Z"); + expect(beach?.endsAt).toBe("2026-08-13T00:00:00.000Z"); + }); + + test("keeps the finished-events back catalogue off the calendar", async () => { + // Fifty-odd past events sit in a table fenced off by nothing but an + //

    Finished Events

    inside a collapsed accordion. A reader blind to + // h4 sees one uninterrupted run of tables and publishes the lot. + const events = await runAdapter(adapter("p5x-game8-events"), fixture); + expect(events).toHaveLength(3); + const titles = events.map((e) => e.title); + expect(titles).not.toContain("Tycoon Season 1"); // finished + expect(titles).not.toContain("New Year's Gifts"); // finished + }); + + test("skips a row whose duration states no date at all", async () => { + // "Take Your Heart" ends 30 days after each player makes an account, so it + // has no calendar date and no honest place on a calendar. + const events = await runAdapter(adapter("p5x-game8-events"), fixture); + expect(events.map((e) => e.title)).not.toContain("Take Your Heart"); + }); + + test("takes no end from an ambiguous one, and does not show it as prose", async () => { + // "June 25, 2026 July 16/30, 2026" names two candidate ends. Picking either + // would be a guess, and echoing the leftover into the summary would dress + // the same guess up as information. + const events = await runAdapter(adapter("p5x-game8-events"), fixture); + const login = events.find((e) => e.title === "Login Campaigns"); + expect(login?.startsAt).toBe("2026-06-25T00:00:00.000Z"); + expect(login?.endsAt).toBeNull(); + expect(login?.summary).toBeNull(); + }); + + test("recovers the blurb from the event's own section", async () => { + // The event is listed twice: once in a bare Event|Duration table, and again + // under its own heading with a paragraph of prose. Deduping by ID must not + // throw the prose away. + const events = await runAdapter(adapter("p5x-game8-events"), fixture); + const anniversary = events.find( + (e) => e.title === "1st Anniversary Celebration", + ); + expect(anniversary?.summary).toContain("first anniversary"); + }); +}); + describe("wiki.gg parser", () => { test("reads exact per-region timers", async () => { // The first source that states region-scoped ends. Asia and the Americas diff --git a/test/dates.test.ts b/test/dates.test.ts index 7cbfe0d..1d0db52 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + parseAdjacentFullRange, parseFullRange, parseLabelledStartEnd, parseMonthDayRange, @@ -129,6 +130,39 @@ describe("parseOpenRange", () => { }); }); +describe("parseAdjacentFullRange", () => { + test("reads two dates separated by nothing but whitespace", () => { + // Persona 5 separates the halves of a duration cell with an
    , which a + // tag-stripping reader flattens to a single space. + expect(parseAdjacentFullRange("July 30, 2026 August 13, 2026")).toEqual({ + start: { iso: "2026-07-30T00:00:00.000Z", precision: "day" }, + end: { iso: "2026-08-13T00:00:00.000Z", precision: "day" }, + }); + }); + + test("requires a year on both halves", () => { + expect(parseAdjacentFullRange("July 30, 2026 August 13")).toBeNull(); + }); + + test("refuses a second half that is only partly a date", () => { + // "July 16/30" names two candidate ends. Reading either as the end would + // be a coin flip presented as a fact. + expect(parseAdjacentFullRange("June 25, 2026 July 16/30, 2026")).toBeNull(); + }); + + test("is anchored, so prose after a date is not read as an end", () => { + // Without the anchors, "Day 3" parses as a month and a day. + expect( + parseAdjacentFullRange("August 12, 2026 Day 3 rewards are doubled"), + ).toBeNull(); + expect(parseAdjacentFullRange("Starts August 12, 2026")).toBeNull(); + }); + + test("rejects an impossible calendar date rather than rolling it over", () => { + expect(parseAdjacentFullRange("February 30, 2026 March 4, 2026")).toBeNull(); + }); +}); + describe("parseLabelledStartEnd", () => { test("reads a labelled cell", () => { expect(