Simplify game files to automatic T5 and T6 uploads

This commit is contained in:
2026-10-04 12:23:36 -04:00
parent 482e27e735
commit 68323c0baa
19 changed files with 588 additions and 232 deletions
+49 -15
View File
@@ -50,10 +50,10 @@ function operations(id: string) {
return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind));
}
async function publish(name = 'T5 files', version = '1', game: 't5' | 't6' = 't5', bytes = content) {
async function publish(game: 't5' | 't6' = 't5', bytes = content) {
vi.setSystemTime(Date.now() + 1);
const upload = libraries.createLibraryUpload({
name, version, game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }]
game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }]
});
await libraries.uploadBrowserFile(upload.id, 'zone/game.ff', {
offset: 0, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex')
@@ -177,17 +177,16 @@ describe('automatic host preparation', () => {
expect(operations(id)).toHaveLength(2);
});
it('downloads the newest published version of every library, including separate libraries for the same game', async () => {
const old = await publish('T5 files', 'old');
const latest = await publish('T5 files', 'new');
const alternate = await publish('T5 alternate');
const t6 = await publish('T6 files', '1', 't6');
it('always targets the latest verified T5 and T6 upload while newer uploads remain unfinished', async () => {
const old = await publish();
const latest = await publish();
const t6 = await publish('t6');
libraries.createLibraryUpload({
name: 'T5 files', version: 'unfinished', game: 't5',
game: 't5',
files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }]
});
const id = enroll({ runtimeImages: [runtime] });
const expected = new Map([latest, alternate, t6].map(version => [version.id, version]));
const expected = new Map([latest, t6].map(version => [version.id, version]));
const downloaded: string[] = [];
for (let index = 0; index < expected.size; index++) {
preparation.reconcileHostPreparation(id);
@@ -218,7 +217,7 @@ describe('automatic host preparation', () => {
verified(id, old);
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
const latest = await publish('T5 files', '2');
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(latest.id);
@@ -226,6 +225,41 @@ describe('automatic host preparation', () => {
expect(libraries.getHostLibraryCache(id, old.id)?.status).toBe('ready');
});
it('replaces an undelivered automatic download when newer files finish uploading', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const first = operations(id)[0];
expect(first.payload).toMatchObject({ libraryVersionId: old.id, automatic: true });
// The next heartbeat may discover a completed cache while this queued
// retry has still never been delivered. Keep that verified evidence.
verified(id, old);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(hosts.getHostOperation(first.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('Superseded') });
expect(operations(id).filter(operation => operation.state === 'pending')).toMatchObject([
{ payload: { libraryVersionId: latest.id, automatic: true } }
]);
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
});
it('finishes a delivered download before following a newer upload under a new operation ID', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const [first] = hosts.pendingHostOperations(id);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
expect(hosts.getHostOperation(first.id)?.state).toBe('pending');
verified(id, old);
hosts.reportHostOperation(id, first.id, { state: 'completed' });
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({ state: 'pending', payload: { libraryVersionId: latest.id } });
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
});
it.each(['draining', 'disabled'] as const)('does not start setup for a %s host', mode => {
const id = enroll();
hosts.updateHost(id, { mode });
@@ -305,15 +339,15 @@ describe('automatic host preparation', () => {
expect(operations(id)).toHaveLength(1);
});
it('lets other libraries prepare when an earlier library cannot fit', async () => {
const oversized = await publish('A oversized');
const small = await publish('B small', '1', 't6', 'small');
it('prepares T6 when the latest T5 upload cannot fit', async () => {
const oversized = await publish();
const small = await publish('t6', 'small');
const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 });
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'A oversized · 1', state: 'blocked' }),
expect.objectContaining({ label: 'B small · 1', state: 'preparing' })
expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' })
]));
expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id);