'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); const { createMemoryTrophyProgressionPlanRepository, createTrophyProgressionPlanner, } = require('../../src/services/trophy-progression-planner-service'); const NOW = Date.parse('2026-08-11T12:00:00.000Z'); const catalog = Object.freeze({ immutable: true, catalogKey: 'np:11111111111111111111111111111111', revision: 'fixture-1', canonicalSha256: 'a'.repeat(64), game: { title: 'Progression Fixture', releaseDate: '2020-01-10T00:00:00.000Z' }, trophies: [ { id: 0, groupId: 0, grade: 'bronze', name: 'First Steps', description: 'Complete the tutorial.', earned: false, earnedDateTime: null }, { id: 1, groupId: 0, grade: 'silver', name: 'Chapter Complete', description: 'Complete chapter five.', earned: false, earnedDateTime: null }, { id: 2, groupId: 0, grade: 'gold', name: 'Collector', description: 'Collect all relics.', earned: false, earnedDateTime: null }, { id: 3, groupId: 0, grade: 'platinum', name: 'Platinum', description: 'Earn every trophy.', earned: false, earnedDateTime: null }, ], }); function planner(value = catalog) { return createTrophyProgressionPlanner({ repository: createMemoryTrophyProgressionPlanRepository(), catalogProvider: { resolve: async input => input.catalogKey === value.catalogKey ? value : null }, now: () => NOW, }); } const baseInput = { catalogKey: catalog.catalogKey, mode: 'synthetic_unlock', trophyIds: [3, 2, 0, 1], startDateTime: '2021-02-01T18:00:00.000Z', endDateTime: '2021-02-08T22:00:00.000Z', minimumIntervalMinutes: 30, strategy: 'balanced', }; test('creates a deterministic import-optional synthetic timeline with platinum last', async () => { const service = planner(); const first = await service.create(baseInput); const second = await service.create(baseInput); assert.equal(first.id, second.id); assert.equal(first.provenance.psnImportRequired, false); assert.equal(first.synthetic, true); assert.equal(first.liveApply.available, false); assert.deepEqual(first.items.map(item => item.trophyId), [0, 1, 2, 3]); assert.equal(first.items.at(-1).grade, 'platinum'); assert.ok(first.items.every(item => item.timestampKind === 'synthetic-estimate')); const times = first.items.map(item => Date.parse(item.proposedEarnedDateTime)); assert.ok(times.every((time, index) => index === 0 || time > times[index - 1])); assert.ok(times[0] >= Date.parse(baseInput.startDateTime)); assert.ok(times.at(-1) <= Date.parse(baseInput.endDateTime)); assert.ok(times.at(-1) <= NOW); }); test('rejects arbitrary trophy IDs, future windows and pre-release history', async () => { const service = planner(); await assert.rejects(service.create({ ...baseInput, trophyIds: [99] }), error => error.code === 'TROPHY_PROGRESSION_SELECTION'); await assert.rejects(service.create({ ...baseInput, endDateTime: '2026-08-12T00:00:00.000Z' }), error => error.code === 'TROPHY_PROGRESSION_FUTURE'); await assert.rejects(service.create({ ...baseInput, startDateTime: '2019-01-01T00:00:00.000Z' }), error => error.code === 'TROPHY_PROGRESSION_RELEASE'); }); test('timestamp edit mode freezes original earned values and changes only explicit selections', async () => { const earnedCatalog = Object.freeze({ ...catalog, canonicalSha256: 'b'.repeat(64), trophies: catalog.trophies.map((item, index) => Object.freeze({ ...item, earned: index < 3, earnedDateTime: index < 3 ? `2021-02-0${index + 2}T20:00:00.000Z` : null, })) }); const service = planner(earnedCatalog); const plan = await service.create({ ...baseInput, mode: 'timestamp_edit', trophyIds: [1], endDateTime: '2021-02-06T22:00:00.000Z' }); assert.equal(plan.items.length, 1); assert.equal(plan.items[0].trophyId, 1); assert.equal(plan.items[0].originalEarnedDateTime, '2021-02-03T20:00:00.000Z'); assert.notEqual(plan.items[0].proposedEarnedDateTime, plan.items[0].originalEarnedDateTime); assert.equal(earnedCatalog.trophies[1].earnedDateTime, '2021-02-03T20:00:00.000Z'); assert.match(plan.confirmationPhrase, /^EDIT LOCAL TROPHY TIMES 1 /); }); test('keeps unselected earned trophies as hard anchors and rejects contradictory anchors', async () => { const anchored = { ...catalog, canonicalSha256: 'c'.repeat(64), trophies: catalog.trophies.map(item => ({ ...item })) }; anchored.trophies[1].earned = true; anchored.trophies[1].earnedDateTime = '2021-02-04T20:00:00.000Z'; const plan = await planner(anchored).create({ ...baseInput, trophyIds: [0, 2, 3] }); const byId = new Map(plan.items.map(item => [item.trophyId, Date.parse(item.proposedEarnedDateTime)])); assert.ok(byId.get(0) < Date.parse(anchored.trophies[1].earnedDateTime)); assert.ok(byId.get(2) > Date.parse(anchored.trophies[1].earnedDateTime)); anchored.canonicalSha256 = 'd'.repeat(64); anchored.trophies[0].earned = true; anchored.trophies[0].earnedDateTime = '2021-02-05T20:00:00.000Z'; await assert.rejects(planner(anchored).create({ ...baseInput, trophyIds: [2, 3] }), error => error.code === 'TROPHY_PROGRESSION_ANCHORS'); }); test('refuses timestamp edits without an exact original local timestamp', async () => { await assert.rejects(planner().create({ ...baseInput, mode: 'timestamp_edit', trophyIds: [0] }), error => error.code === 'TROPHY_PROGRESSION_ORIGINAL'); });