From dbddbd687b59b2abc12b197119b58c0b8c4fe05b Mon Sep 17 00:00:00 2001 From: Ael Date: Tue, 21 Jul 2026 19:35:01 +0200 Subject: [PATCH] feat(spacetime): stage generic four-worker authority --- package.json | 1 + ...erify-castle-worker-additive-migration.mjs | 57 ++ spacetimedb/README.md | 20 +- .../additive-v12-schema/package.json | 13 + .../additive-v12-schema/src/index.ts | 608 ++++++++++++++ .../additive-v12-schema/tsconfig.json | 13 + spacetimedb/src/castleWorkerAuthority.ts | 785 ++++++++++++++++++ spacetimedb/src/castleWorkerPolicy.ts | 314 +++++++ spacetimedb/src/castleWorkerRoster.ts | 118 +++ spacetimedb/src/foundingAuthority.ts | 6 + spacetimedb/src/index.ts | 10 + spacetimedb/src/reducers/castleWorkers.ts | 270 ++++++ spacetimedb/src/reducers/resources.ts | 7 + .../resourceExpeditionReservationAuthority.ts | 45 +- spacetimedb/src/schema.ts | 197 +++++ .../castleWorkerMigrationTooling.test.ts | 74 ++ spacetimedb/tests/castleWorkerPolicy.test.ts | 73 ++ .../tests/foodExpeditionReducers.test.ts | 2 +- .../tests/goldExpeditionReducers.test.ts | 4 +- .../tests/playerIdentityPrivacy.test.ts | 4 + spacetimedb/tests/resourceReducers.test.ts | 6 + .../tests/stoneExpeditionReducers.test.ts | 2 +- .../tests/waterRevisionAuthority.test.ts | 2 +- .../tests/woodExpeditionReducers.test.ts | 2 +- ..._get_worker_system_status_v_1_procedure.ts | 19 + .../admin_plan_worker_roster_v_1_procedure.ts | 19 + .../castle_worker_v_1_table.ts | 32 + .../dispatch_worker_v_1_reducer.ts | 18 + .../get_my_resource_state_v_2_procedure.ts | 19 + .../get_my_worker_roster_v_1_procedure.ts | 19 + src/spacetime/module_bindings/index.ts | 77 ++ .../realm_worker_system_v_1_table.ts | 24 + .../recall_all_workers_v_1_reducer.ts | 15 + .../recall_worker_v_1_reducer.ts | 16 + src/spacetime/module_bindings/types.ts | 195 +++++ .../module_bindings/types/procedures.ts | 12 + .../module_bindings/types/reducers.ts | 6 + .../worker_assignment_schedule_v_1_table.ts | 20 + .../worker_node_occupation_v_1_table.ts | 26 + 39 files changed, 3134 insertions(+), 16 deletions(-) create mode 100644 scripts/verify-castle-worker-additive-migration.mjs create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/package.json create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts create mode 100644 spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json create mode 100644 spacetimedb/src/castleWorkerAuthority.ts create mode 100644 spacetimedb/src/castleWorkerPolicy.ts create mode 100644 spacetimedb/src/castleWorkerRoster.ts create mode 100644 spacetimedb/src/reducers/castleWorkers.ts create mode 100644 spacetimedb/tests/castleWorkerMigrationTooling.test.ts create mode 100644 spacetimedb/tests/castleWorkerPolicy.test.ts create mode 100644 src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/castle_worker_v_1_table.ts create mode 100644 src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts create mode 100644 src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts create mode 100644 src/spacetime/module_bindings/realm_worker_system_v_1_table.ts create mode 100644 src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/recall_worker_v_1_reducer.ts create mode 100644 src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts create mode 100644 src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts diff --git a/package.json b/package.json index 556acada..8c0e5c67 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "stdb:generate": "node scripts/generate-spacetime-bindings.mjs", "stdb:verify-bindings": "node scripts/verify-spacetime-bindings.mjs", "stdb:verify-additive-migration": "node scripts/verify-spacetime-additive-migration.mjs", + "stdb:verify-worker-migration": "node scripts/verify-castle-worker-additive-migration.mjs", "stdb:publish:dev": "node scripts/publish-spacetime-dev.mjs", "stdb:seed-world": "tsx scripts/hermes-admin.ts seed-world", "stdb:expand-world-v3": "tsx scripts/hermes-admin.ts expand-world-v3", diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs new file mode 100644 index 00000000..4ccaf426 --- /dev/null +++ b/scripts/verify-castle-worker-additive-migration.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = resolve(root, 'spacetimedb/src/schema.ts'); +const previousFixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v11-schema/src/index.ts'); +const fixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts'); + +function registrations(source, marker) { + const start = source.indexOf(marker); + const end = source.indexOf('\n});', start); + assert.ok(start >= 0 && end > start, `missing schema marker: ${marker}`); + return source.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function table(source, name) { + const start = source.indexOf(`const ${name} = table(`); + const end = source.indexOf('\n);', start); + assert.ok(start >= 0 && end > start, `missing table: ${name}`); + return source.slice(start, end); +} + +const [schema, previousFixture, fixture] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(previousFixturePath, 'utf8'), + readFile(fixturePath, 'utf8'), +]); +const current = registrations(schema, 'const warpkeep = schema({'); +const previous = registrations(previousFixture, 'const db = schema({'); +const candidate = registrations(fixture, 'const db = schema({'); +assert.equal(previous.length, 47, 'v11 fixture must end at ref 46'); +assert.deepEqual(current.slice(0, 47), previous, 'current schema changed before the v12 suffix'); +assert.deepEqual(candidate.slice(0, 47), previous, 'v12 fixture changed the deployed prefix'); +assert.deepEqual(candidate.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', +]); +assert.deepEqual(current.slice(47), candidate.slice(47), 'module and fixture suffix differ'); +for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1', 'workerAssignmentScheduleV1']) { + const definition = table(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|accruedAmount|materializedAmount|balance|requestKey|auth/i); +} +for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1']) { + assert.doesNotMatch(table(schema, name), /public: true/); +} +assert.match(fixture, /fixture_seed_generic_worker_sentinel_v12/); +console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, public/private boundary checked'); diff --git a/spacetimedb/README.md b/spacetimedb/README.md index 0735acd3..d4da369c 100644 --- a/spacetimedb/README.md +++ b/spacetimedb/README.md @@ -12,8 +12,9 @@ balance, advance a timer, or decide an expedition outcome. | Browser/backend wire protocol | 3 | | Player authentication contract | 2 | | Genesis world generation | 3 | -| Append-only schema generation | 10 | +| Append-only schema generation | 12 (staged suffix) | | Alpha 0.3.12 suffix | Water refs 37–40; Stone refs 41–45 | +| Generic worker suffix | refs 47–52; staged, not activated | Deployed tables retain their original declaration order and shape. Later features append new tables; they do not rename or delete existing data. The @@ -55,6 +56,8 @@ Public subscriptions contain only shared-world presentation: - activated Water layout, body/cell topology, and shared environment data; - identity-minimized site occupations containing a site, phase, public timeline, and origin castle; +- staged four-worker roster and generic node-lease projections; the public + rows contain no FID, cargo, accrual, balance, request, or auth data; - public Community Marks projection only when its policy permits it. Private tables contain admission, ownership, unclaimed-slot decisions, resource @@ -94,6 +97,21 @@ Gold, Food, Wood, and Stone each have an independent expedition: - private reservations prevent passive collection or another lifecycle from truncating a valid Food, Wood, or Stone award. +The additive generic-worker suffix defines four stable workers per founded +castle. Any idle worker can gather Gold, Food, Wood, or Stone, and multiple +workers may gather the same resource at different nodes. Worker assignments +use the same canonical site catalogs, route authority, 60-second quantum, and +30-day cap as the legacy expeditions. The caller's private read projects exact +server-time availability without a write; scheduled expiry and explicit +dispatch/recall commands materialize complete quanta. There is no per-minute +write loop and no `collect` command for generic workers. + +The suffix is intentionally staged. The module does not seed, activate, +backfill, or migrate production workers in this PR. Activation requires an +admin-reviewed singleton, an exact four-worker roster digest, and zero legacy +expedition, occupation, and schedule rows. The browser wire protocol remains 3 +until a later client-capability release opts into the generic worker methods. + ## Entry agreement and Marks Entry and gameplay require the exact current Alpha Terms and Hegemony Social diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/package.json b/spacetimedb/migration-fixtures/additive-v12-schema/package.json new file mode 100644 index 00000000..0caa87d6 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/package.json @@ -0,0 +1,13 @@ +{ + "name": "warpkeep-additive-v12-schema-migration-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "Apache-2.0", + "dependencies": { + "spacetimedb": "2.6.1" + }, + "devDependencies": { + "typescript": "5.6.3" + } +} diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts new file mode 100644 index 00000000..1b6ac58e --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts @@ -0,0 +1,608 @@ +import { schema, table, t } from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import { + goldExpeditionErrorCode, + runGoldExpeditionSchedule, +} from '../../../src/goldExpeditionAuthority'; +import { + foodExpeditionErrorCode, + runFoodExpeditionSchedule, +} from '../../../src/foodExpeditionAuthority'; +import { + woodExpeditionErrorCode, + runWoodExpeditionSchedule, +} from '../../../src/woodExpeditionAuthority'; +import { + stoneExpeditionErrorCode, + runStoneExpeditionSchedule, +} from '../../../src/stoneExpeditionAuthority'; + +const allowedFid = table({ name: 'allowed_fid' }, { + fid: t.u64().primaryKey(), enabled: t.bool(), authEpoch: t.u32(), + invitedAt: t.timestamp(), invitedBy: t.string(), note: t.string(), +}); +const worldTile = table({ name: 'world_tile', public: true }, { + key: t.string().primaryKey(), q: t.i32(), r: t.i32(), biome: t.string(), + terrainSeed: t.u32(), occupantCastleId: t.option(t.u64()), +}); +const player = table({ name: 'player', public: true }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), username: t.option(t.string()), + displayName: t.option(t.string()), pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const castle = table({ name: 'castle', public: true }, { + castleId: t.u64().primaryKey().autoInc(), ownerFid: t.u64().unique(), tileKey: t.string().unique(), + q: t.i32(), r: t.i32(), level: t.i32(), name: t.string(), createdAt: t.timestamp(), +}); +const adminAudit = table({ name: 'admin_audit' }, { + id: t.u64().primaryKey().autoInc(), action: t.string(), targetFid: t.option(t.u64()), + actorSubject: t.string(), createdAt: t.timestamp(), note: t.string(), +}); +const playerV2 = table({ name: 'player_v2', public: true }, { + fid: t.u64().primaryKey(), username: t.option(t.string()), displayName: t.option(t.string()), + pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(), +}); +const playerOwnershipV2 = table({ name: 'player_ownership_v2' }, { + fid: t.u64().primaryKey(), identity: t.identity().unique(), +}); +const realmV1 = table({ name: 'realm_v1', public: true }, { + realmId: t.string().primaryKey(), publicName: t.string(), seedName: t.string(), numericSeed: t.u32(), + generationVersion: t.u32(), authoritativeRadius: t.u32(), renderRadius: t.u32(), playerCapacity: t.u32(), + active: t.bool(), createdAt: t.timestamp(), +}); +const worldTileMetaV1 = table({ + name: 'world_tile_meta_v1', public: true, + indexes: [{ accessor: 'byRealmAndRing', algorithm: 'btree', columns: ['realmId', 'ring'] as const }] as const, +}, { + tileKey: t.string().primaryKey(), realmId: t.string().index(), s: t.i32(), ring: t.u32(), sector: t.u32(), + terrainKind: t.string(), passable: t.bool(), movementCost: t.u32(), staticContentKind: t.string(), generationVersion: t.u32(), +}); +const castleSlotV1 = table({ name: 'castle_slot_v1', public: true }, { + slotId: t.u32().primaryKey(), realmId: t.string().index(), tileKey: t.string().unique(), q: t.i32(), r: t.i32(), generationVersion: t.u32(), +}); +const castleSlotClaimV1 = table({ name: 'castle_slot_claim_v1' }, { + slotId: t.u32().primaryKey(), ownerFid: t.u64().unique(), castleId: t.u64().unique(), claimedAt: t.timestamp(), generationVersion: t.u32(), +}); +const realmProfileV1 = table({ name: 'realm_profile_v1', public: true }, { + fid: t.u64().primaryKey(), canonicalUsername: t.option(t.string()), displayName: t.option(t.string()), pfpUrl: t.option(t.string()), publicBio: t.option(t.string()), + admittedAt: t.timestamp(), firstAuthenticatedAt: t.option(t.timestamp()), profileUpdatedAt: t.timestamp(), publicStatus: t.string(), communityStatsVisible: t.bool(), + totalSnapBurnedMicros: t.option(t.u128()), marksEarnedMicros: t.option(t.u128()), marksSpentMicros: t.option(t.u128()), marksBalanceMicros: t.option(t.u128()), marksPolicyVersion: t.option(t.string()), +}); +const markAccountV1 = table({ name: 'mark_account_v1' }, { + fid: t.u64().primaryKey(), totalSnapBurnedMicros: t.u128(), earnedMicros: t.u128(), spentMicros: t.u128(), balanceMicros: t.u128(), policyVersion: t.string(), updatedAt: t.timestamp(), +}); +const snapBurnCreditV1 = table({ name: 'snap_burn_credit_v1' }, { + eventKey: t.string().primaryKey(), batchId: t.string().index(), chainId: t.u32(), tokenContract: t.string(), transactionHash: t.string(), logIndex: t.u32(), burnReference: t.string().unique(), burnMethod: t.string(), senderAddress: t.string(), blockNumber: t.u64(), blockHash: t.string(), amountMicros: t.u128(), attributedFid: t.u64().index(), attributionPolicyVersion: t.string(), contractCodeHash: t.string(), creditedAt: t.timestamp(), +}); +const fidWalletAttributionV1 = table({ + name: 'fid_wallet_attribution_v1', indexes: [{ accessor: 'bySnapshotAndAddress', algorithm: 'btree', columns: ['snapshotGeneration', 'address'] as const }] as const, +}, { + snapshotAttributionKey: t.string().primaryKey(), attributionKey: t.string(), snapshotGeneration: t.u64(), fid: t.u64().index(), address: t.string(), addressType: t.string(), source: t.string(), snapshotAt: t.timestamp(), attributionPolicyVersion: t.string(), active: t.bool(), +}); +const walletAttributionSnapshotV1 = table({ name: 'wallet_attribution_snapshot_v1' }, { + snapshotKey: t.string().primaryKey(), generation: t.u64(), snapshotId: t.string(), policyVersion: t.string(), attributionCount: t.u32(), snapshotAt: t.timestamp(), +}); +const snapScanCursorV1 = table({ name: 'snap_scan_cursor_v1' }, { + cursorKey: t.string().primaryKey(), chainId: t.u32(), tokenContract: t.string(), policyVersion: t.string(), deploymentStartBlock: t.u64(), lastFinalizedBlock: t.u64(), lastFinalizedBlockHash: t.string(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), scannedAt: t.timestamp(), +}); +const snapScanBatchV1 = table({ + name: 'snap_scan_batch_v1', indexes: [{ accessor: 'byCursorAndStatus', algorithm: 'btree', columns: ['cursorKey', 'status'] as const }] as const, +}, { + batchId: t.string().primaryKey(), cursorKey: t.string(), status: t.string(), previousFinalizedBlock: t.u64(), previousFinalizedBlockHash: t.string(), throughFinalizedBlock: t.u64(), throughFinalizedBlockHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), walletAttributionCount: t.u32(), expectedCredits: t.u32(), expectedMicros: t.u128(), appliedCredits: t.u32(), appliedMicros: t.u128(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), startedAt: t.timestamp(), finalizedAt: t.option(t.timestamp()), +}); +const alphaTermsAcceptanceV1 = table({ name: 'alpha_terms_acceptance_v1' }, { + acceptanceKey: t.string().primaryKey(), fid: t.u64().index(), termsVersion: t.string(), acceptedAt: t.timestamp(), +}); +const resourceAccountV1 = table({ name: 'resource_account_v1' }, { + fid: t.u64().primaryKey(), castleId: t.u64().unique(), realmId: t.string().index(), food: t.u64(), wood: t.u64(), stone: t.u64(), gold: t.u64(), settledThroughMicros: t.u64(), revision: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); + +const goldSiteV1 = table({ name: 'gold_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const goldNodeOccupationV1 = table({ name: 'gold_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const goldExpeditionV1 = table({ name: 'gold_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedGold: t.u64(), creditedGold: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const goldExpeditionIdempotencyV1 = table({ name: 'gold_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const goldExpeditionScheduleV1 = table({ name: 'gold_expedition_schedule_v_1', public: true, scheduled: (): any => runGoldExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmForestLayoutV1 = table({ name: 'realm_forest_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), layoutDigest: t.string(), assetCatalogDigest: t.string(), instanceCount: t.u32(), seededAt: t.timestamp() }); +const realmForestInstanceV1 = table({ name: 'realm_forest_instance_v1', public: true }, { treeId: t.string().primaryKey(), realmId: t.string().index(), tileKey: t.string(), q: t.i32(), r: t.i32(), localXMicrounits: t.i64(), localZMicrounits: t.i64(), worldXMicrounits: t.i64(), worldZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), scaleBasisPoints: t.u32(), speciesId: t.string(), habitat: t.string(), layoutVersion: t.u32() }); + +const foodSiteV1 = table({ name: 'food_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const foodNodeOccupationV1 = table({ name: 'food_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const foodExpeditionV1 = table({ name: 'food_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedFood: t.u64(), creditedFood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const foodExpeditionIdempotencyV1 = table({ name: 'food_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const foodExpeditionScheduleV1 = table({ name: 'food_expedition_schedule_v_1', public: true, scheduled: (): any => runFoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const woodSiteV1 = table({ name: 'wood_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const woodNodeOccupationV1 = table({ name: 'wood_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const woodExpeditionV1 = table({ name: 'wood_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedWood: t.u64(), creditedWood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const woodExpeditionIdempotencyV1 = table({ name: 'wood_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const woodExpeditionScheduleV1 = table({ name: 'wood_expedition_schedule_v_1', public: true, scheduled: (): any => runWoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterLayoutV1 = table({ name: 'realm_water_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), generationVersion: t.u32(), canonicalLandCellCount: t.u32(), oceanCellCount: t.u32(), lakeCellCount: t.u32(), lakeBodyCount: t.u32(), riverCount: t.u32(), riverCellCount: t.u32(), seaLevelMilli: t.i32(), seaLevelPolicyVersion: t.string(), fogStartDepthCells: t.u32(), fogFullDepthCells: t.u32(), hiddenBufferCells: t.u32(), layoutDigest: t.string(), sourceCommit: t.string(), activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()) }); +const realmWaterBodyV1 = table({ name: 'realm_water_body_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }] as const }, { bodyId: t.string().primaryKey(), realmId: t.string().index(), regime: t.string(), cellCount: t.u32(), sourceCellKey: t.string(), mouthCellKey: t.string(), surfaceLevelMilli: t.i32(), flowDirectionXQ15: t.i32(), flowDirectionZQ15: t.i32(), wavePreset: t.string(), ordinal: t.u32(), seed: t.u32(), generationVersion: t.u32(), layoutVersion: t.u32() }); +const realmWaterCellV1 = table({ name: 'realm_water_cell_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }, { accessor: 'byBody', algorithm: 'btree', columns: ['bodyId'] as const }] as const }, { cellKey: t.string().primaryKey(), realmId: t.string().index(), q: t.i32(), r: t.i32(), regime: t.string(), bodyId: t.string(), depthCells: t.u32(), elevationMilli: t.i32(), surfaceLevelMilli: t.i32(), ring: t.u32(), s: t.i32(), underlyingTileKey: t.option(t.string()), riverOrdinal: t.option(t.u32()), riverOrder: t.option(t.u32()), downstreamWaterCellKey: t.option(t.string()), flowAccumulation: t.u32(), depthClass: t.u32(), oceanDepth: t.u32(), bankSeed: t.u32(), generationVersion: t.u32(), fogBand: t.string(), layoutVersion: t.u32() }); +const realmEnvironmentV1 = table({ name: 'realm_environment_v1', public: true }, { realmId: t.string().primaryKey(), environmentEpoch: t.u64(), waterLayoutVersion: t.u32(), seaLevelMilli: t.i32(), sunDirectionXMicro: t.i32(), sunDirectionYMicro: t.i32(), sunDirectionZMicro: t.i32(), updatedAt: t.timestamp() }); + +const stoneSiteV1 = table({ name: 'stone_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() }); +const stoneNodeOccupationV1 = table({ name: 'stone_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() }); +const stoneExpeditionV1 = table({ name: 'stone_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedStone: t.u64(), creditedStone: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() }); +const stoneExpeditionIdempotencyV1 = table({ name: 'stone_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() }); +const stoneExpeditionScheduleV1 = table({ name: 'stone_expedition_schedule_v_1', public: true, scheduled: (): any => runStoneExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() }); + +const realmWaterRevisionV1 = table({ name: 'realm_water_revision_v1', public: true }, { + realmId: t.string().primaryKey(), revisionVersion: t.u32(), policyVersion: t.string(), + baseLayoutVersion: t.u32(), baseLayoutDigest: t.string(), oceanBodyCount: t.u32(), + riverBodyCount: t.u32(), enabledBodyCount: t.u32(), oceanCellCount: t.u32(), + riverCellCount: t.u32(), enabledCellCount: t.u32(), lakeBodyCount: t.u32(), + lakeCellCount: t.u32(), riverWidthCells: t.u32(), navigationFogBoundaryDepthCells: t.u32(), + hiddenBufferCells: t.u32(), revisionDigest: t.string(), sourceCommit: t.string(), + activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()), +}); + +/** v12 generic-worker suffix. Public rows contain only identity/lifecycle data. */ +const realmWorkerSystemV1 = table({ name: 'realm_worker_system_v1', public: true }, { + realmId: t.string().primaryKey(), policyVersion: t.string(), workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), expectedWorkerCount: t.u32(), rosterDigest: t.string(), + mode: t.string(), legacyDrainRequired: t.bool(), createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), +}); +const castleWorkerV1 = table({ + name: 'castle_worker_v1', public: true, + indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const, +}, { + workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(), + assignmentId: t.option(t.string()), resourceKind: t.option(t.string()), siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerAssignmentV1 = table({ + name: 'worker_assignment_v1', + indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const, +}, { + assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(), + originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.u64(), routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), settledThroughMicros: t.u64(), accruedAmount: t.u64(), + materializedAmount: t.u64(), timelineRevision: t.u32(), policyVersion: t.string(), + createdAt: t.timestamp(), updatedAt: t.timestamp(), +}); +const workerNodeOccupationV1 = table({ + name: 'worker_node_occupation_v1', public: true, + indexes: [ + { accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, +}, { + nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(), + workerOrdinal: t.u32(), originCastleId: t.u64(), assignmentId: t.string(), phase: t.string(), + startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(), +}); +const workerCommandIdempotencyV1 = table({ + name: 'worker_command_idempotency_v1', + indexes: [{ accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }] as const, +}, { + requestKey: t.string().primaryKey(), fid: t.u64(), workerId: t.option(t.string()), commandKind: t.string(), + resourceKind: t.option(t.string()), siteId: t.option(t.string()), assignmentId: t.option(t.string()), + resultRevision: t.u64(), createdAt: t.timestamp(), +}); +const workerAssignmentScheduleV1 = table({ + name: 'worker_assignment_schedule_v_1', public: true, + indexes: [ + { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const }, + { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const }, + ] as const, + scheduled: (): any => runWorkerAssignmentScheduleV1, +}, { + scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), assignmentId: t.string(), + workerId: t.string(), timelineRevision: t.u32(), stage: t.string(), +}); + +const db = schema({ + allowedFid, worldTile, player, castle, adminAudit, playerV2, playerOwnershipV2, + realmV1, worldTileMetaV1, castleSlotV1, castleSlotClaimV1, realmProfileV1, markAccountV1, + snapBurnCreditV1, fidWalletAttributionV1, walletAttributionSnapshotV1, snapScanCursorV1, + snapScanBatchV1, alphaTermsAcceptanceV1, resourceAccountV1, goldSiteV1, goldNodeOccupationV1, + goldExpeditionV1, goldExpeditionIdempotencyV1, goldExpeditionScheduleV1, realmForestLayoutV1, + realmForestInstanceV1, foodSiteV1, foodNodeOccupationV1, foodExpeditionV1, + foodExpeditionIdempotencyV1, foodExpeditionScheduleV1, woodSiteV1, woodNodeOccupationV1, + woodExpeditionV1, woodExpeditionIdempotencyV1, woodExpeditionScheduleV1, realmWaterLayoutV1, + realmWaterBodyV1, realmWaterCellV1, realmEnvironmentV1, stoneSiteV1, + stoneNodeOccupationV1, stoneExpeditionV1, stoneExpeditionIdempotencyV1, + stoneExpeditionScheduleV1, realmWaterRevisionV1, realmWorkerSystemV1, castleWorkerV1, + workerAssignmentV1, workerNodeOccupationV1, workerCommandIdempotencyV1, workerAssignmentScheduleV1, +}); + +export const runWorkerAssignmentScheduleV1 = db.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + () => {}, +); + +export const runGoldExpeditionScheduleV1 = db.reducer( + { name: 'run_gold_expedition_schedule_v_1' }, + { arg: goldExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runGoldExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = goldExpeditionErrorCode(error); throw new SenderError(code ?? 'GOLD_SCHEDULE_ERROR'); } + }, +); +export const runFoodExpeditionScheduleV1 = db.reducer( + { name: 'run_food_expedition_schedule_v_1' }, + { arg: foodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runFoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = foodExpeditionErrorCode(error); throw new SenderError(code ?? 'FOOD_SCHEDULE_ERROR'); } + }, +); +export const runWoodExpeditionScheduleV1 = db.reducer( + { name: 'run_wood_expedition_schedule_v_1' }, + { arg: woodExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runWoodExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = woodExpeditionErrorCode(error); throw new SenderError(code ?? 'WOOD_SCHEDULE_ERROR'); } + }, +); +export const runStoneExpeditionScheduleV1 = db.reducer( + { name: 'run_stone_expedition_schedule_v_1' }, + { arg: stoneExpeditionScheduleV1.rowType }, + (ctx, { arg }) => { + try { runStoneExpeditionSchedule(ctx as any, arg as any); } + catch (error) { const code = stoneExpeditionErrorCode(error); throw new SenderError(code ?? 'STONE_SCHEDULE_ERROR'); } + }, +); + +/** Auth-neutral identity fixture; SQL identity literals are issuer-bound. */ +export const fixtureInsertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_insert_player_ownership_v9' }, + { fid: t.u64() }, + (ctx, { fid }) => { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_EXISTS'); + ctx.db.playerOwnershipV2.insert({ fid, identity: ctx.sender }); + }, +); + +/** Bounded identity-row assertion; SQL cannot read identity columns across issuers. */ +export const fixtureAssertPlayerOwnershipV9 = db.reducer( + { name: 'fixture_assert_player_ownership_v9' }, + { fid: t.u64(), expectedCount: t.u64() }, + (ctx, { fid, expectedCount }) => { + if (ctx.db.playerOwnershipV2.count() !== expectedCount) throw new Error('FIXTURE_OWNERSHIP_COUNT_INVALID'); + if (expectedCount === 0n) { + if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_UNEXPECTED'); + return; + } + if (expectedCount !== 1n || ctx.db.playerOwnershipV2.fid.find(fid) === null) { + throw new Error('FIXTURE_OWNERSHIP_ROW_INVALID'); + } + }, +); + +/** Preserve the v9 Water sentinel wire unchanged in the v10 fixture. */ +export const fixtureSeedWaterSentinelV9 = db.reducer( + { name: 'fixture_seed_water_sentinel_v9' }, + ctx => { + if ( + ctx.db.realmWaterLayoutV1.count() !== 0n + || ctx.db.realmWaterBodyV1.count() !== 0n + || ctx.db.realmWaterCellV1.count() !== 0n + || ctx.db.realmEnvironmentV1.count() !== 0n + ) throw new Error('FIXTURE_WATER_NOT_EMPTY'); + const realmId = 'MIGRATION_WATER_SENTINEL'; + const bodyId = 'migration-water-body'; + ctx.db.realmWaterLayoutV1.insert({ + realmId, + layoutVersion: 1, + policyVersion: 'migration-water-sentinel-v1', + generationVersion: 3, + canonicalLandCellCount: 10_000, + oceanCellCount: 1, + lakeCellCount: 0, + lakeBodyCount: 0, + riverCount: 0, + riverCellCount: 0, + seaLevelMilli: 0, + seaLevelPolicyVersion: 'migration-water-sentinel-v1', + fogStartDepthCells: 1, + fogFullDepthCells: 2, + hiddenBufferCells: 1, + layoutDigest: '0'.repeat(64), + sourceCommit: '0'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + ctx.db.realmWaterBodyV1.insert({ + bodyId, + realmId, + regime: 'ocean', + cellCount: 1, + sourceCellKey: 'migration-water-cell', + mouthCellKey: 'migration-water-cell', + surfaceLevelMilli: 0, + flowDirectionXQ15: 0, + flowDirectionZQ15: 0, + wavePreset: 'migration', + ordinal: 0, + seed: 0, + generationVersion: 3, + layoutVersion: 1, + }); + ctx.db.realmWaterCellV1.insert({ + cellKey: 'migration-water-cell', + realmId, + q: 0, + r: 0, + regime: 'ocean', + bodyId, + depthCells: 1, + elevationMilli: 0, + surfaceLevelMilli: 0, + ring: 0, + s: 0, + underlyingTileKey: undefined, + riverOrdinal: undefined, + riverOrder: undefined, + downstreamWaterCellKey: undefined, + flowAccumulation: 0, + depthClass: 1, + oceanDepth: 1, + bankSeed: 0, + generationVersion: 3, + fogBand: 'clear', + layoutVersion: 1, + }); + ctx.db.realmEnvironmentV1.insert({ + realmId, + environmentEpoch: 1n, + waterLayoutVersion: 1, + seaLevelMilli: 0, + sunDirectionXMicro: 0, + sunDirectionYMicro: 1_000_000, + sunDirectionZMicro: 0, + updatedAt: ctx.timestamp, + }); + }, +); + +/** One typed row per v10 Stone table for the next additive migration. */ +export const fixtureSeedStoneSentinelV10 = db.reducer( + { name: 'fixture_seed_stone_sentinel_v10' }, + ctx => { + if ( + ctx.db.stoneSiteV1.count() !== 0n + || ctx.db.stoneNodeOccupationV1.count() !== 0n + || ctx.db.stoneExpeditionV1.count() !== 0n + || ctx.db.stoneExpeditionIdempotencyV1.count() !== 0n + || ctx.db.stoneExpeditionScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_STONE_NOT_EMPTY'); + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 7n * 24n * 60n * 60n * 1_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 24n * 60n * 60n * 1_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 24n * 60n * 60n * 1_000_000n; + const siteId = 'migration-stone-site'; + const expeditionId = 'migration-stone-expedition'; + const originCastleId = 991_001n; + const fid = 991_002n; + ctx.db.stoneSiteV1.insert({ siteId, q: 1, r: -1, tier: 1, active: true }); + ctx.db.stoneNodeOccupationV1.insert({ + siteId, + originCastleId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + }); + ctx.db.stoneExpeditionV1.insert({ + expeditionId, + fid, + originCastleId, + siteId, + phase: 'outbound', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnsAtMicros, + settledThroughMicros: startedAtMicros, + accruedStone: 0n, + creditedStone: 0n, + policyVersion: 'migration-stone-sentinel-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionIdempotencyV1.insert({ + requestKey: 'migration-stone-sentinel-request-0001', + fid, + siteId, + expeditionId, + createdAt: ctx.timestamp, + }); + ctx.db.stoneExpeditionScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(arrivesAtMicros), + originCastleId, + siteId, + stage: 'arrival', + }); + }, +); + +/** Typed v11 sentinel used only to prove rollback refusal and row survival. */ +export const fixtureSeedWaterRevisionSentinelV11 = db.reducer( + { name: 'fixture_seed_water_revision_sentinel_v11' }, + ctx => { + if (ctx.db.realmWaterRevisionV1.count() !== 0n) { + throw new Error('FIXTURE_WATER_REVISION_NOT_EMPTY'); + } + ctx.db.realmWaterRevisionV1.insert({ + realmId: 'MIGRATION_WATER_SENTINEL', + revisionVersion: 2, + policyVersion: 'migration-water-revision-sentinel-v1', + baseLayoutVersion: 1, + baseLayoutDigest: '0'.repeat(64), + oceanBodyCount: 1, + riverBodyCount: 0, + enabledBodyCount: 1, + oceanCellCount: 1, + riverCellCount: 0, + enabledCellCount: 1, + lakeBodyCount: 0, + lakeCellCount: 0, + riverWidthCells: 1, + navigationFogBoundaryDepthCells: 2, + hiddenBufferCells: 1, + revisionDigest: '1'.repeat(64), + sourceCommit: '1'.repeat(40), + activated: false, + seededAt: ctx.timestamp, + activatedAt: undefined, + }); + }, +); + +const FIXTURE_RESOURCE_QUANTUM_MICROS = 600_000_000n; +const FIXTURE_RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1'; + +export const fixtureRewindResourceOneQuantum = db.reducer( + { name: 'fixture_rewind_resource_one_quantum' }, + { fid: t.u64() }, + (ctx, { fid }) => { + const row = ctx.db.resourceAccountV1.fid.find(fid); + if ( + row === null + || row.policyVersion !== FIXTURE_RESOURCE_POLICY_VERSION + || row.revision !== 0n + || row.food !== 0n + || row.wood !== 0n + || row.stone !== 0n + || row.gold !== 0n + || row.settledThroughMicros < FIXTURE_RESOURCE_QUANTUM_MICROS + ) throw new Error('FIXTURE_RESOURCE_STATE_INVALID'); + const rewoundMicros = row.settledThroughMicros - FIXTURE_RESOURCE_QUANTUM_MICROS; + ctx.db.resourceAccountV1.fid.update({ + ...row, + settledThroughMicros: rewoundMicros, + createdAt: new Timestamp(rewoundMicros), + updatedAt: ctx.timestamp, + }); + }, +); + +/** Populates every v12 table with bounded, auth-neutral rows for migration proof. */ +export const fixtureSeedGenericWorkerSentinelV12 = db.reducer( + { name: 'fixture_seed_generic_worker_sentinel_v12' }, + ctx => { + if ( + ctx.db.realmWorkerSystemV1.count() !== 0n + || ctx.db.castleWorkerV1.count() !== 0n + || ctx.db.workerAssignmentV1.count() !== 0n + || ctx.db.workerNodeOccupationV1.count() !== 0n + || ctx.db.workerCommandIdempotencyV1.count() !== 0n + || ctx.db.workerAssignmentScheduleV1.count() !== 0n + ) throw new Error('FIXTURE_WORKER_NOT_EMPTY'); + const castleId = 991_101n; + const fid = 991_102n; + const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch; + const arrivesAtMicros = startedAtMicros + 30_000_000n; + const gatheringEndsAtMicros = arrivesAtMicros + 86_400_000_000n; + const returnsAtMicros = gatheringEndsAtMicros + 30_000_000n; + const assignmentId = 'migration-worker-assignment-0001'; + const workerId = 'genesis-001-castle-991101-worker-01'; + const siteId = 'migration-worker-site'; + ctx.db.realmWorkerSystemV1.insert({ + realmId: 'GENESIS_001', + policyVersion: 'genesis-001-castle-workers-v1', + workersPerCastle: 4, + expectedCastleCount: 1, + expectedWorkerCount: 4, + rosterDigest: 'migration-worker-roster-digest', + mode: 'staged', + legacyDrainRequired: true, + createdAt: ctx.timestamp, + activatedAt: undefined, + }); + for (let ordinal = 1; ordinal <= 4; ordinal += 1) { + ctx.db.castleWorkerV1.insert({ + workerId: `genesis-001-castle-991101-worker-0${ordinal}`, + originCastleId: castleId, + ordinal, + status: ordinal === 1 ? 'gathering' : 'idle', + assignmentId: ordinal === 1 ? assignmentId : undefined, + resourceKind: ordinal === 1 ? 'stone' : undefined, + siteId: ordinal === 1 ? siteId : undefined, + startedAtMicros: ordinal === 1 ? startedAtMicros : undefined, + arrivesAtMicros: ordinal === 1 ? arrivesAtMicros : undefined, + gatheringEndsAtMicros: ordinal === 1 ? gatheringEndsAtMicros : undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: ordinal === 1 ? returnsAtMicros : undefined, + routeSteps: ordinal === 1 ? 1 : undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + ctx.db.workerAssignmentV1.insert({ + assignmentId, + workerId, + fid, + originCastleId: castleId, + resourceKind: 'stone', + siteId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros, + routeSteps: 1, + returnStartProgressBasisPoints: 0, + settledThroughMicros: arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: 'genesis-001-castle-workers-v1', + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey: 'stone:migration-worker-site', + resourceKind: 'stone', + siteId, + workerId, + workerOrdinal: 1, + originCastleId: castleId, + assignmentId, + phase: 'gathering', + startedAtMicros, + arrivesAtMicros, + gatheringEndsAtMicros, + timelineRevision: 0, + }); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey: '991102:migration-worker-request-0001', + fid, + workerId, + commandKind: 'dispatch', + resourceKind: 'stone', + siteId, + assignmentId, + resultRevision: 0n, + createdAt: ctx.timestamp, + }); + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(gatheringEndsAtMicros), + assignmentId, + workerId, + timelineRevision: 0, + stage: 'gathering-expiry', + }); + }, +); + +export default db; diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json new file mode 100644 index 00000000..ff6a5945 --- /dev/null +++ b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/spacetimedb/src/castleWorkerAuthority.ts b/spacetimedb/src/castleWorkerAuthority.ts new file mode 100644 index 00000000..0be9eb06 --- /dev/null +++ b/spacetimedb/src/castleWorkerAuthority.ts @@ -0,0 +1,785 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; + +import { assertGenesisResourceForFid } from './resourceAuthority'; +import { RESOURCE_BALANCE_CAP } from './resourceAuthorityPolicy'; +import { + activeExpeditionResourceReservations, + planResourceSettlementForActiveExpeditionReservations, +} from './resourceExpeditionReservationAuthority'; +import type warpkeep from './schema'; +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, + CASTLE_WORKERS_PER_CASTLE, + CastleWorkerPolicyError, + type CastleWorkerPhase, + type CastleWorkerSiteShape, + planCastleWorkerAccrual, + planCastleWorkerTimeline, + rosterDigestForCastleIds, + workerAssignmentStateIsConsistent, + workerIdForCastle, + workerResourcePolicy, + assertCastleWorkerId, + assertWorkerCommandKey, + canonicalWorkerRouteSteps, +} from './castleWorkerPolicy'; +import { + assertCastleWorkerRoster, + workerRosterDigestInput, + workerSystemRowIsStagedOrActive, +} from './castleWorkerRoster'; +import { + CANONICAL_REALM, + canonicalMetaForKey, + canonicalTileForKey, + matchesCanonicalTerrain, + matchesCanonicalWorldMeta, +} from './world'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type WorkerRow = NonNullable>; +type AssignmentRow = NonNullable>; +type ScheduleRow = NonNullable>; +type ResourceAccountRow = NonNullable>; + +export const WORKER_SCHEDULE_STAGE_ARRIVAL = 'arrival'; +export const WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY = 'gathering-expiry'; +export const WORKER_SCHEDULE_STAGE_RETURN_COMPLETE = 'return-complete'; +const WORKER_SYSTEM_REALM_ID = CANONICAL_REALM.realmId; +const WORKER_TIMELINE_MAX = 0xffff_ffff; + +export class CastleWorkerAuthorityError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'CastleWorkerAuthorityError'; + } +} + +function fail(code: string): never { + throw new CastleWorkerAuthorityError(code); +} + +function safeNextU32(value: number, code: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value >= WORKER_TIMELINE_MAX) fail(code); + return value + 1; +} + +function safeNextU64(value: bigint, code: string): bigint { + if (value < 0n || value >= (1n << 64n) - 1n) fail(code); + return value + 1n; +} + +function assignmentRequestKey(fid: bigint, idempotencyKey: string): string { + assertWorkerCommandKey(idempotencyKey); + return `${fid.toString()}:${idempotencyKey}`; +} + +function resourceField(kind: string): 'food' | 'wood' | 'stone' | 'gold' { + if (kind === 'food' || kind === 'wood' || kind === 'stone' || kind === 'gold') return kind; + fail('WORKER_RESOURCE_UNSUPPORTED'); +} + +function assignmentPhase(value: string): CastleWorkerPhase { + if (value === 'outbound' || value === 'gathering' || value === 'returning') return value; + fail('WORKER_PHASE_INVALID'); +} + +function systemRow(ctx: WarpkeepReducerContext) { + const row = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); + if (row === null || !workerSystemRowIsStagedOrActive(row)) fail('WORKER_SYSTEM_NOT_READY'); + return row; +} + +function workerSystemActive(ctx: WarpkeepReducerContext) { + const row = systemRow(ctx); + if (row.mode !== 'active') fail('WORKER_SYSTEM_STAGED'); + if (row.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED'); + const castleIds = [...ctx.db.castle.iter()].map(castle => castle.castleId); + const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE); + if ( + BigInt(row.expectedCastleCount) !== BigInt(castleIds.length) + || BigInt(row.expectedWorkerCount) !== expectedWorkerCount + || ctx.db.castleWorkerV1.count() !== expectedWorkerCount + || row.rosterDigest !== rosterDigestForCastleIds(castleIds) + ) fail('WORKER_ROSTER_NOT_READY'); + for (const castleId of castleIds) assertCastleWorkerRoster(ctx, castleId); + for (const worker of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(worker.originCastleId) === null) { + fail('WORKER_ROSTER_ORPHAN'); + } + } + const legacy = legacyActiveCounts(ctx); + if (legacy.expeditions !== 0n || legacy.occupations !== 0n || legacy.schedules !== 0n) { + fail('WORKER_LEGACY_DRAIN_REQUIRED'); + } + return row; +} + +function canonicalSiteFor( + ctx: WarpkeepReducerContext, + resourceKind: string, + siteId: string, +): CastleWorkerSiteShape { + const policy = workerResourcePolicy(resourceKind); + const canonical = policy.canonicalSiteForId(siteId); + if (canonical === undefined || !canonical.active || !policy.matchesCanonicalSite(canonical)) { + fail('WORKER_SITE_UNAVAILABLE'); + } + const stored = resourceKind === 'gold' + ? ctx.db.goldSiteV1.siteId.find(siteId) + : resourceKind === 'food' + ? ctx.db.foodSiteV1.siteId.find(siteId) + : resourceKind === 'wood' + ? ctx.db.woodSiteV1.siteId.find(siteId) + : ctx.db.stoneSiteV1.siteId.find(siteId); + if (stored === null || !policy.matchesCanonicalSite(stored)) fail('WORKER_SITE_INTEGRITY'); + const tileKey = `${canonical.q},${canonical.r}`; + const tile = ctx.db.worldTile.key.find(tileKey); + const meta = ctx.db.worldTileMetaV1.tileKey.find(tileKey); + const expectedTile = canonicalTileForKey(tileKey); + const expectedMeta = canonicalMetaForKey(tileKey); + if ( + tile === null + || meta === null + || expectedTile === undefined + || expectedMeta === undefined + || !matchesCanonicalTerrain(tile) + || !matchesCanonicalWorldMeta(meta) + || !matchesCanonicalTerrain(expectedTile) + || !matchesCanonicalWorldMeta(expectedMeta) + || !meta.passable + || meta.staticContentKind !== 'resource-capable' + || tile.q !== canonical.q + || tile.r !== canonical.r + ) fail('WORKER_SITE_WORLD_INTEGRITY'); + return canonical; +} + +function legacyOccupationAt(ctx: WarpkeepReducerContext, resourceKind: string, siteId: string): boolean { + return resourceKind === 'gold' + ? ctx.db.goldNodeOccupationV1.siteId.find(siteId) !== null + : resourceKind === 'food' + ? ctx.db.foodNodeOccupationV1.siteId.find(siteId) !== null + : resourceKind === 'wood' + ? ctx.db.woodNodeOccupationV1.siteId.find(siteId) !== null + : ctx.db.stoneNodeOccupationV1.siteId.find(siteId) !== null; +} + +function publicWorkerMatchesAssignment(worker: WorkerRow, assignment: AssignmentRow): boolean { + return worker.workerId === assignment.workerId + && worker.originCastleId === assignment.originCastleId + && worker.status === assignment.phase + && worker.assignmentId === assignment.assignmentId + && worker.resourceKind === assignment.resourceKind + && worker.siteId === assignment.siteId + && worker.startedAtMicros === assignment.startedAtMicros + && worker.arrivesAtMicros === assignment.arrivesAtMicros + && worker.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros + && worker.returnsAtMicros === assignment.returnsAtMicros + && worker.routeSteps === assignment.routeSteps + && worker.timelineRevision === assignment.timelineRevision; +} + +function assertAssignmentState(assignment: AssignmentRow): void { + assignmentPhase(assignment.phase); + if ( + assignment.workerId.length === 0 + || assignment.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || !workerAssignmentStateIsConsistent(assignment) + || assignment.returnStartProgressBasisPoints > 10_000 + ) fail('WORKER_ASSIGNMENT_STATE_INVALID'); +} + +function insertSchedule( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + stage: string, + atMicros: bigint, +): void { + ctx.db.workerAssignmentScheduleV1.insert({ + scheduleId: 0n, + scheduledAt: ScheduleAt.time(atMicros), + assignmentId: assignment.assignmentId, + workerId: assignment.workerId, + timelineRevision: assignment.timelineRevision, + stage, + }); +} + +function updateResourceAccount( + ctx: WarpkeepReducerContext, + resource: ResourceAccountRow, + balances: ResourceAccountRow, + passiveSettledThroughMicros: bigint, + revision: bigint, +): void { + ctx.db.resourceAccountV1.fid.update({ + ...resource, + food: balances.food, + wood: balances.wood, + stone: balances.stone, + gold: balances.gold, + settledThroughMicros: passiveSettledThroughMicros, + revision, + updatedAt: ctx.timestamp, + }); +} + +/** + * Materialize every complete worker quantum for one caller in one transaction. + * Reads use the sibling projection below and never call this writer. No + * per-minute writes occur: schedules and caller reads settle exact quanta. + */ +export function settleAllWorkerAssignmentsForFid( + ctx: WarpkeepReducerContext, + fid: bigint, + observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, +): void { + const resource = assertGenesisResourceForFid(ctx, fid); + const passive = planResourceSettlementForActiveExpeditionReservations( + ctx, + fid, + resource.account, + resource.terrainKind, + observedAtMicros, + ); + const balances = { + ...resource.account, + food: passive.balances.food, + wood: passive.balances.wood, + stone: passive.balances.stone, + gold: passive.balances.gold, + }; + let changed = passive.completedQuanta > 0n; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + if (assignment.fid !== fid) continue; + assertAssignmentState(assignment); + if (assignment.fid !== fid || assignment.originCastleId !== resource.castle.castleId) fail('WORKER_OWNER_INTEGRITY'); + const plan = planCastleWorkerAccrual(assignment, observedAtMicros); + const credit = plan.accruedAmount - assignment.materializedAmount; + if (credit < 0n) fail('WORKER_MATERIALIZATION_INVALID'); + if (plan.completedQuanta === 0n && credit === 0n) continue; + const field = resourceField(assignment.resourceKind); + if (credit > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY'); + balances[field] += credit; + ctx.db.workerAssignmentV1.assignmentId.update({ + ...assignment, + settledThroughMicros: plan.settledThroughMicros, + accruedAmount: plan.accruedAmount, + materializedAmount: plan.accruedAmount, + updatedAt: ctx.timestamp, + }); + changed = true; + } + if (changed) { + updateResourceAccount( + ctx, + resource.account, + balances, + passive.settledThroughMicros, + safeNextU64(resource.account.revision, 'WORKER_RESOURCE_REVISION'), + ); + } +} + +export type WorkerPrivateProjection = Readonly<{ + workerId: string; + ordinal: number; + status: string; + resourceKind: string | undefined; + siteId: string | undefined; + accruedAmount: bigint; + materializedAmount: bigint; + availableAmount: bigint; + observedAtMicros: bigint; + revision: bigint; +}>; + +export function projectMyWorkerState( + ctx: WarpkeepReducerContext, + fid: bigint, + observedAtMicros = ctx.timestamp.microsSinceUnixEpoch, +): Readonly<{ resource: ResourceAccountRow; balances: Readonly>; workers: readonly WorkerPrivateProjection[] }> { + const resource = assertGenesisResourceForFid(ctx, fid); + const passive = planResourceSettlementForActiveExpeditionReservations( + ctx, + fid, + resource.account, + resource.terrainKind, + observedAtMicros, + ); + const balances = { + food: passive.balances.food, + wood: passive.balances.wood, + stone: passive.balances.stone, + gold: passive.balances.gold, + }; + const workers = [...assertCastleWorkerRoster(ctx, resource.castle.castleId)] + .sort((left, right) => left.ordinal - right.ordinal) + .map(worker => { + const assignment = worker.assignmentId === undefined + ? undefined + : ctx.db.workerAssignmentV1.assignmentId.find(worker.assignmentId); + if (assignment === undefined || assignment === null) { + if (worker.assignmentId !== undefined) fail('WORKER_ASSIGNMENT_MISSING'); + return Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: 0n, + materializedAmount: 0n, + availableAmount: 0n, + observedAtMicros, + revision: worker.revision, + }); + } + assertAssignmentState(assignment); + if (!publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + const plan = planCastleWorkerAccrual(assignment, observedAtMicros); + const availableAmount = plan.accruedAmount - assignment.materializedAmount; + if (availableAmount < 0n) fail('WORKER_MATERIALIZATION_INVALID'); + const field = resourceField(assignment.resourceKind); + if (availableAmount > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY'); + balances[field] += availableAmount; + return Object.freeze({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: plan.accruedAmount, + materializedAmount: assignment.materializedAmount, + availableAmount, + observedAtMicros, + revision: worker.revision, + }); + }); + return Object.freeze({ resource: resource.account, balances: Object.freeze(balances), workers: Object.freeze(workers) }); +} + +function assertDispatchReservations( + ctx: WarpkeepReducerContext, + fid: bigint, + account: ResourceAccountRow, + resourceKind: string, +): void { + const policy = workerResourcePolicy(resourceKind); + const reservations = activeExpeditionResourceReservations(ctx, fid); + const field = resourceField(resourceKind); + const existingReservation = reservations[field]; + if (account[field] > RESOURCE_BALANCE_CAP || existingReservation > RESOURCE_BALANCE_CAP) fail('WORKER_ACCOUNT_STATE_INVALID'); + if (policy.gatheringTotal > RESOURCE_BALANCE_CAP - account[field] - existingReservation) { + fail('WORKER_ACCOUNT_CAPACITY'); + } +} + +export type WorkerDispatchResult = Readonly<{ assignment: AssignmentRow; idempotent: boolean }>; + +export function dispatchCastleWorker( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>, +): WorkerDispatchResult { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'dispatch' || prior.workerId !== input.workerId || prior.resourceKind !== input.resourceKind || prior.siteId !== input.siteId || prior.assignmentId === undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(prior.assignmentId); + if (assignment === null) fail('WORKER_IDEMPOTENCY_STALE'); + return Object.freeze({ assignment, idempotent: true }); + } + workerSystemActive(ctx); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if (worker === null || worker.originCastleId !== input.castle.castleId || !roster.some(row => row.workerId === worker.workerId)) fail('WORKER_NOT_OWNED'); + assertCastleWorkerId(worker.workerId); + if (worker.status !== 'idle' || worker.assignmentId !== undefined) fail('WORKER_NOT_IDLE'); + const site = canonicalSiteFor(ctx, input.resourceKind, input.siteId); + if (legacyOccupationAt(ctx, input.resourceKind, input.siteId)) fail('WORKER_LEGACY_SITE_OCCUPIED'); + const nodeKey = `${input.resourceKind}:${input.siteId}`; + if (ctx.db.workerNodeOccupationV1.nodeKey.find(nodeKey) !== null) fail('WORKER_SITE_OCCUPIED'); + const routeSteps = canonicalWorkerRouteSteps(input.castle, site); + if (routeSteps === undefined || routeSteps <= 0) fail('WORKER_ROUTE_INVALID'); + const resource = assertGenesisResourceForFid(ctx, input.fid); + assertDispatchReservations(ctx, input.fid, resource.account, input.resourceKind); + const timeline = planCastleWorkerTimeline(ctx.timestamp.microsSinceUnixEpoch, routeSteps); + const assignment = ctx.db.workerAssignmentV1.insert({ + assignmentId: ctx.newUuidV7().toString(), + workerId: worker.workerId, + fid: input.fid, + originCastleId: input.castle.castleId, + resourceKind: input.resourceKind, + siteId: input.siteId, + phase: 'outbound', + ...timeline, + returnStartedAtMicros: undefined, + routeSteps, + returnStartProgressBasisPoints: 0, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + timelineRevision: 0, + policyVersion: CASTLE_WORKER_POLICY_VERSION, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'outbound', + assignmentId: assignment.assignmentId, + resourceKind: input.resourceKind, + siteId: input.siteId, + startedAtMicros: assignment.startedAtMicros, + arrivesAtMicros: assignment.arrivesAtMicros, + gatheringEndsAtMicros: assignment.gatheringEndsAtMicros, + returnStartedAtMicros: undefined, + returnsAtMicros: assignment.returnsAtMicros, + routeSteps: assignment.routeSteps, + returnStartProgressBasisPoints: undefined, + updatedAt: ctx.timestamp, + }); + ctx.db.workerNodeOccupationV1.insert({ + nodeKey, + resourceKind: input.resourceKind, + siteId: input.siteId, + workerId: worker.workerId, + workerOrdinal: worker.ordinal, + originCastleId: input.castle.castleId, + assignmentId: assignment.assignmentId, + phase: 'outbound', + startedAtMicros: assignment.startedAtMicros, + arrivesAtMicros: assignment.arrivesAtMicros, + gatheringEndsAtMicros: assignment.gatheringEndsAtMicros, + timelineRevision: assignment.timelineRevision, + }); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_ARRIVAL, assignment.arrivesAtMicros); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY, assignment.gatheringEndsAtMicros); + insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, assignment.returnsAtMicros); + ctx.db.workerCommandIdempotencyV1.insert({ + requestKey, + fid: input.fid, + workerId: worker.workerId, + commandKind: 'dispatch', + resourceKind: input.resourceKind, + siteId: input.siteId, + assignmentId: assignment.assignmentId, + resultRevision: worker.revision, + createdAt: ctx.timestamp, + }); + return Object.freeze({ assignment, idempotent: false }); +} + +function progressBasisPoints(assignment: AssignmentRow, now: bigint): number { + if (now <= assignment.startedAtMicros) return 0; + if (now >= assignment.arrivesAtMicros) return 10_000; + const elapsed = now - assignment.startedAtMicros; + const duration = assignment.arrivesAtMicros - assignment.startedAtMicros; + return Number((elapsed * 10_000n) / duration); +} + +function remainingTravelMicros(assignment: AssignmentRow, progress: number): bigint { + const travel = BigInt(assignment.routeSteps) * CASTLE_WORKER_TRAVEL_MICROS_PER_STEP; + // The return path starts at the worker's current outbound position: zero + // progress is still at the castle, while 10,000 is at the node. + return (travel * BigInt(progress)) / 10_000n; +} + +function beginWorkerReturn( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + progress: number, + now: bigint, +): AssignmentRow { + assertAssignmentState(assignment); + if (assignment.phase !== 'outbound' && assignment.phase !== 'gathering') return assignment; + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (occupation !== null) { + if (occupation.assignmentId !== assignment.assignmentId || occupation.workerId !== assignment.workerId || occupation.timelineRevision !== assignment.timelineRevision) fail('WORKER_OCCUPATION_INTEGRITY'); + ctx.db.workerNodeOccupationV1.nodeKey.delete(occupation.nodeKey); + } + const returningAtMicros = now + remainingTravelMicros(assignment, progress); + const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION'); + const returning = { + ...assignment, + phase: 'returning', + returnStartedAtMicros: now, + returnsAtMicros: returningAtMicros, + returnStartProgressBasisPoints: progress, + timelineRevision, + updatedAt: ctx.timestamp, + }; + ctx.db.workerAssignmentV1.assignmentId.update(returning); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'returning', + returnStartedAtMicros: now, + returnsAtMicros: returningAtMicros, + returnStartProgressBasisPoints: progress, + timelineRevision, + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + updatedAt: ctx.timestamp, + }); + insertSchedule(ctx, returning, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, returningAtMicros); + return returning; +} + +function completeWorkerReturn(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): void { + if (now < assignment.returnsAtMicros) return; + if (assignment.phase !== 'returning') fail('WORKER_RETURN_STATE'); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + ctx.db.workerAssignmentV1.assignmentId.delete(assignment.assignmentId); + ctx.db.castleWorkerV1.workerId.update({ + ...worker, + status: 'idle', + assignmentId: undefined, + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION'), + revision: safeNextU64(worker.revision, 'WORKER_REVISION'), + updatedAt: ctx.timestamp, + }); +} + +function transitionWorkerArrival(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): AssignmentRow { + if (now < assignment.arrivesAtMicros) return assignment; + if (assignment.phase !== 'outbound') return assignment; + const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`); + if (occupation === null || occupation.assignmentId !== assignment.assignmentId || occupation.timelineRevision !== assignment.timelineRevision) fail('WORKER_OCCUPATION_MISSING'); + const gathering = { ...assignment, phase: 'gathering', updatedAt: ctx.timestamp }; + ctx.db.workerAssignmentV1.assignmentId.update(gathering); + ctx.db.workerNodeOccupationV1.nodeKey.update({ ...occupation, phase: 'gathering' }); + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH'); + ctx.db.castleWorkerV1.workerId.update({ ...worker, status: 'gathering', updatedAt: ctx.timestamp }); + return gathering; +} + +function settleAndBeginReturnAt( + ctx: WarpkeepReducerContext, + assignment: AssignmentRow, + now: bigint, + progress: number, +): AssignmentRow { + settleAllWorkerAssignmentsForFid(ctx, assignment.fid, now); + const fresh = ctx.db.workerAssignmentV1.assignmentId.find(assignment.assignmentId); + if (fresh === null) fail('WORKER_ASSIGNMENT_MISSING'); + return beginWorkerReturn(ctx, fresh, progress, now); +} + +export function runCastleWorkerSchedule(ctx: WarpkeepReducerContext, schedule: ScheduleRow): void { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId); + if (assignment === null || assignment.workerId !== schedule.workerId || assignment.timelineRevision !== schedule.timelineRevision) return; + assertAssignmentState(assignment); + const now = ctx.timestamp.microsSinceUnixEpoch; + if (schedule.stage === WORKER_SCHEDULE_STAGE_ARRIVAL) { + transitionWorkerArrival(ctx, assignment, now); + return; + } + if (schedule.stage === WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY) { + const gathering = transitionWorkerArrival(ctx, assignment, now); + if (now < gathering.gatheringEndsAtMicros || gathering.phase === 'returning') return; + settleAndBeginReturnAt(ctx, gathering, gathering.gatheringEndsAtMicros, 10_000); + return; + } + if (schedule.stage !== WORKER_SCHEDULE_STAGE_RETURN_COMPLETE) return; + let current = assignment; + if (current.phase === 'outbound' && now >= current.arrivesAtMicros) current = transitionWorkerArrival(ctx, current, now); + if ((current.phase === 'outbound' || current.phase === 'gathering') && now >= current.gatheringEndsAtMicros) { + current = settleAndBeginReturnAt(ctx, current, current.gatheringEndsAtMicros, 10_000); + } + completeWorkerReturn(ctx, current, now); +} + +export function recallCastleWorker( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; idempotencyKey: string }>, +): void { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'recall' || prior.workerId !== input.workerId) fail('WORKER_IDEMPOTENCY_CONFLICT'); + return; + } + workerSystemActive(ctx); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId); + if (worker === null || worker.originCastleId !== input.castle.castleId) fail('WORKER_NOT_OWNED'); + assertCastleWorkerRoster(ctx, input.castle.castleId); + if (worker.assignmentId === undefined) { + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: undefined, siteId: undefined, assignmentId: undefined, resultRevision: worker.revision, createdAt: ctx.timestamp }); + return; + } + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(worker.assignmentId); + if (assignment === null || assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING'); + assertAssignmentState(assignment); + if (assignment.phase === 'outbound') { + beginWorkerReturn(ctx, assignment, progressBasisPoints(assignment, ctx.timestamp.microsSinceUnixEpoch), ctx.timestamp.microsSinceUnixEpoch); + } else if (assignment.phase === 'gathering') { + beginWorkerReturn(ctx, assignment, 10_000, ctx.timestamp.microsSinceUnixEpoch); + } + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: worker.workerId, commandKind: 'recall', resourceKind: assignment.resourceKind, siteId: assignment.siteId, assignmentId: assignment.assignmentId, resultRevision: worker.revision, createdAt: ctx.timestamp }); +} + +export function recallAllCastleWorkers( + ctx: WarpkeepReducerContext, + input: Readonly<{ fid: bigint; castle: CastleRow; idempotencyKey: string }>, +): void { + const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey); + const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey); + if (prior !== null) { + if (prior.fid !== input.fid || prior.commandKind !== 'recall-all' || prior.workerId !== undefined) fail('WORKER_IDEMPOTENCY_CONFLICT'); + return; + } + workerSystemActive(ctx); + const roster = assertCastleWorkerRoster(ctx, input.castle.castleId); + settleAllWorkerAssignmentsForFid(ctx, input.fid); + const now = ctx.timestamp.microsSinceUnixEpoch; + let lastAssignmentId: string | undefined; + for (const worker of [...roster].sort((left, right) => left.ordinal - right.ordinal)) { + const fresh = ctx.db.castleWorkerV1.workerId.find(worker.workerId); + if (fresh === null || fresh.originCastleId !== input.castle.castleId) fail('WORKER_ROSTER_INTEGRITY'); + if (fresh.assignmentId === undefined) continue; + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(fresh.assignmentId); + if (assignment === null || assignment.fid !== input.fid || !publicWorkerMatchesAssignment(fresh, assignment)) fail('WORKER_ASSIGNMENT_INTEGRITY'); + if (assignment.phase === 'outbound') { + lastAssignmentId = beginWorkerReturn(ctx, assignment, progressBasisPoints(assignment, now), now).assignmentId; + } else if (assignment.phase === 'gathering') { + lastAssignmentId = beginWorkerReturn(ctx, assignment, 10_000, now).assignmentId; + } + } + ctx.db.workerCommandIdempotencyV1.insert({ requestKey, fid: input.fid, workerId: undefined, commandKind: 'recall-all', resourceKind: undefined, siteId: undefined, assignmentId: lastAssignmentId, resultRevision: 0n, createdAt: ctx.timestamp }); +} + +export type WorkerGraphAggregate = Readonly<{ + systemRows: bigint; + mode: string; + expectedCastleCount: bigint; + expectedWorkerCount: bigint; + actualWorkerCount: bigint; + castlesMissingWorkers: bigint; + castlesWithExtraWorkers: bigint; + duplicateOrdinals: bigint; + malformedWorkerIds: bigint; + idleWorkers: bigint; + outboundWorkers: bigint; + gatheringWorkers: bigint; + returningWorkers: bigint; + assignments: bigint; + occupations: bigint; + schedules: bigint; + orphanWorkers: bigint; + orphanAssignments: bigint; + orphanOccupations: bigint; + assignmentPublicMismatches: bigint; + occupationSiteMismatches: bigint; + legacyExpeditions: bigint; + legacyOccupations: bigint; + legacySchedules: bigint; + rosterDigest: string; + rosterDigestExpected: string; +}>; + +function legacyActiveCounts(ctx: WarpkeepReducerContext): Readonly<{ expeditions: bigint; occupations: bigint; schedules: bigint }> { + return Object.freeze({ + expeditions: ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count() + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count(), + occupations: ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count() + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count(), + schedules: ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count() + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count(), + }); +} + +export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGraphAggregate { + const system = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID); + const castles = [...ctx.db.castle.iter()].sort((left, right) => left.castleId < right.castleId ? -1 : left.castleId > right.castleId ? 1 : 0); + let castlesMissingWorkers = 0n; + let castlesWithExtraWorkers = 0n; + let duplicateOrdinals = 0n; + let malformedWorkerIds = 0n; + let orphanWorkers = 0n; + let idleWorkers = 0n; + let outboundWorkers = 0n; + let gatheringWorkers = 0n; + let returningWorkers = 0n; + for (const castle of castles) { + const rows = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + if (rows.length < CASTLE_WORKERS_PER_CASTLE) castlesMissingWorkers += 1n; + if (rows.length > CASTLE_WORKERS_PER_CASTLE) castlesWithExtraWorkers += 1n; + const ordinals = new Set(); + for (const row of rows) { + try { assertCastleWorkerId(row.workerId); } catch { malformedWorkerIds += 1n; } + if (ordinals.has(row.ordinal)) duplicateOrdinals += 1n; + ordinals.add(row.ordinal); + if (row.status === 'idle') idleWorkers += 1n; + if (row.status === 'outbound') outboundWorkers += 1n; + if (row.status === 'gathering') gatheringWorkers += 1n; + if (row.status === 'returning') returningWorkers += 1n; + } + } + for (const row of ctx.db.castleWorkerV1.iter()) { + if (ctx.db.castle.castleId.find(row.originCastleId) === null) orphanWorkers += 1n; + } + let orphanAssignments = 0n; + let assignmentPublicMismatches = 0n; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId); + if (worker === null) orphanAssignments += 1n; + else if (!publicWorkerMatchesAssignment(worker, assignment)) assignmentPublicMismatches += 1n; + } + let orphanOccupations = 0n; + let occupationSiteMismatches = 0n; + for (const occupation of ctx.db.workerNodeOccupationV1.iter()) { + const assignment = ctx.db.workerAssignmentV1.assignmentId.find(occupation.assignmentId); + if (assignment === null) orphanOccupations += 1n; + if (occupation.nodeKey !== `${occupation.resourceKind}:${occupation.siteId}`) occupationSiteMismatches += 1n; + if (assignment !== null && (assignment.phase === 'returning' || assignment.siteId !== occupation.siteId || assignment.resourceKind !== occupation.resourceKind)) occupationSiteMismatches += 1n; + } + const legacy = legacyActiveCounts(ctx); + const castleIds = castles.map(castle => castle.castleId); + return Object.freeze({ + systemRows: ctx.db.realmWorkerSystemV1.count(), + mode: system?.mode ?? 'absent', + expectedCastleCount: BigInt(system?.expectedCastleCount ?? 0), + expectedWorkerCount: BigInt(system?.expectedWorkerCount ?? 0), + actualWorkerCount: ctx.db.castleWorkerV1.count(), + castlesMissingWorkers, + castlesWithExtraWorkers, + duplicateOrdinals, + malformedWorkerIds, + idleWorkers, + outboundWorkers, + gatheringWorkers, + returningWorkers, + assignments: ctx.db.workerAssignmentV1.count(), + occupations: ctx.db.workerNodeOccupationV1.count(), + schedules: ctx.db.workerAssignmentScheduleV1.count(), + orphanWorkers, + orphanAssignments, + orphanOccupations, + assignmentPublicMismatches, + occupationSiteMismatches, + legacyExpeditions: legacy.expeditions, + legacyOccupations: legacy.occupations, + legacySchedules: legacy.schedules, + rosterDigest: system?.rosterDigest ?? '', + rosterDigestExpected: rosterDigestForCastleIds(castleIds), + }); +} + +export function castleWorkerErrorCode(error: unknown): string | undefined { + if (error instanceof CastleWorkerAuthorityError || error instanceof CastleWorkerPolicyError) return error.code; + return undefined; +} diff --git a/spacetimedb/src/castleWorkerPolicy.ts b/spacetimedb/src/castleWorkerPolicy.ts new file mode 100644 index 00000000..cc396a64 --- /dev/null +++ b/spacetimedb/src/castleWorkerPolicy.ts @@ -0,0 +1,314 @@ +import { + FOOD_EXPEDITION_POLICY_VERSION, + FOOD_GATHERING_DURATION_MICROS, + FOOD_GATHER_QUANTUM_MICROS, + FOOD_GATHER_RATE_PER_QUANTUM, +} from './foodExpeditionPolicy'; +import { + GENESIS_TIER_I_FOOD_SITE_COUNT, + GENESIS_TIER_I_FOOD_SITE_DIGEST, + FOOD_SITE_POLICY_VERSION, + canonicalFoodSiteV1ForId, + matchesCanonicalTierIFoodSiteV1, +} from './foodSitePolicy'; +import { + GOLD_EXPEDITION_POLICY_VERSION, + GOLD_GATHERING_DURATION_MICROS, + GOLD_GATHER_QUANTUM_MICROS, + GOLD_GATHER_RATE_PER_QUANTUM, +} from './goldExpeditionPolicy'; +import { + GENESIS_TIER_I_GOLD_SITE_COUNT, + GENESIS_TIER_I_GOLD_SITE_DIGEST, + GOLD_SITE_POLICY_VERSION, + canonicalGoldSiteV1ForId, + matchesCanonicalTierIGoldSiteV1, + canonicalPassableRouteSteps, +} from './goldSitePolicy'; +import { + STONE_EXPEDITION_POLICY_VERSION, + STONE_GATHERING_DURATION_MICROS, + STONE_GATHER_QUANTUM_MICROS, + STONE_GATHER_RATE_PER_QUANTUM, +} from './stoneExpeditionPolicy'; +import { + GENESIS_TIER_I_STONE_SITE_COUNT, + GENESIS_TIER_I_STONE_SITE_DIGEST, + STONE_SITE_POLICY_VERSION, + canonicalStoneSiteV1ForId, + matchesCanonicalTierIStoneSiteV1, +} from './stoneSitePolicy'; +import { + WOOD_EXPEDITION_POLICY_VERSION, + WOOD_GATHERING_DURATION_MICROS, + WOOD_GATHER_QUANTUM_MICROS, + WOOD_GATHER_RATE_PER_QUANTUM, +} from './woodExpeditionPolicy'; +import { + GENESIS_TIER_I_WOOD_SITE_COUNT, + GENESIS_TIER_I_WOOD_SITE_DIGEST, + WOOD_SITE_POLICY_VERSION, + canonicalWoodSiteV1ForId, + matchesCanonicalTierIWoodSiteV1, +} from './woodSitePolicy'; + +export const CASTLE_WORKERS_PER_CASTLE = 4; +export const CASTLE_WORKER_POLICY_VERSION = 'genesis-001-castle-workers-v1'; +export const CASTLE_WORKER_GATHER_QUANTUM_MICROS = 60_000_000n; +export const CASTLE_WORKER_TRAVEL_MICROS_PER_STEP = 30_000_000n; +export const CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS = 30n * 24n * 60n * 60n * 1_000_000n; +export const CASTLE_WORKER_U64_MAX = (1n << 64n) - 1n; +export const CASTLE_WORKER_PROTOCOL_CAPABILITY = 'generic-castle-workers-v1'; + +export type WorkerResourceKind = 'gold' | 'food' | 'wood' | 'stone'; +export type CastleWorkerPhase = 'outbound' | 'gathering' | 'returning'; +export type CastleWorkerStatus = 'idle' | CastleWorkerPhase; + +export type CastleWorkerSiteShape = Readonly<{ + siteId: string; + q: number; + r: number; + tier: number; + active: boolean; +}>; + +export type CastleWorkerResourcePolicy = Readonly<{ + kind: WorkerResourceKind; + siteTable: string; + sitePolicyVersion: string; + siteCatalogDigest: string; + canonicalSiteCount: number; + expeditionPolicyVersion: string; + quantumMicros: bigint; + ratePerQuantum: bigint; + gatheringDurationMicros: bigint; + gatheringTotal: bigint; + canonicalSiteForId: (siteId: string) => CastleWorkerSiteShape | undefined; + matchesCanonicalSite: (site: CastleWorkerSiteShape) => boolean; +}>; + +const RESOURCE_POLICIES: Readonly> = Object.freeze({ + gold: Object.freeze({ + kind: 'gold', + siteTable: 'goldSiteV1', + sitePolicyVersion: GOLD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_GOLD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_GOLD_SITE_COUNT, + expeditionPolicyVersion: GOLD_EXPEDITION_POLICY_VERSION, + quantumMicros: GOLD_GATHER_QUANTUM_MICROS, + ratePerQuantum: GOLD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: GOLD_GATHERING_DURATION_MICROS, + gatheringTotal: (GOLD_GATHERING_DURATION_MICROS / GOLD_GATHER_QUANTUM_MICROS) * GOLD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalGoldSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIGoldSiteV1, + }), + food: Object.freeze({ + kind: 'food', + siteTable: 'foodSiteV1', + sitePolicyVersion: FOOD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_FOOD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_FOOD_SITE_COUNT, + expeditionPolicyVersion: FOOD_EXPEDITION_POLICY_VERSION, + quantumMicros: FOOD_GATHER_QUANTUM_MICROS, + ratePerQuantum: FOOD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: FOOD_GATHERING_DURATION_MICROS, + gatheringTotal: (FOOD_GATHERING_DURATION_MICROS / FOOD_GATHER_QUANTUM_MICROS) * FOOD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalFoodSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIFoodSiteV1, + }), + wood: Object.freeze({ + kind: 'wood', + siteTable: 'woodSiteV1', + sitePolicyVersion: WOOD_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_WOOD_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_WOOD_SITE_COUNT, + expeditionPolicyVersion: WOOD_EXPEDITION_POLICY_VERSION, + quantumMicros: WOOD_GATHER_QUANTUM_MICROS, + ratePerQuantum: WOOD_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: WOOD_GATHERING_DURATION_MICROS, + gatheringTotal: (WOOD_GATHERING_DURATION_MICROS / WOOD_GATHER_QUANTUM_MICROS) * WOOD_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalWoodSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIWoodSiteV1, + }), + stone: Object.freeze({ + kind: 'stone', + siteTable: 'stoneSiteV1', + sitePolicyVersion: STONE_SITE_POLICY_VERSION, + siteCatalogDigest: GENESIS_TIER_I_STONE_SITE_DIGEST, + canonicalSiteCount: GENESIS_TIER_I_STONE_SITE_COUNT, + expeditionPolicyVersion: STONE_EXPEDITION_POLICY_VERSION, + quantumMicros: STONE_GATHER_QUANTUM_MICROS, + ratePerQuantum: STONE_GATHER_RATE_PER_QUANTUM, + gatheringDurationMicros: STONE_GATHERING_DURATION_MICROS, + gatheringTotal: (STONE_GATHERING_DURATION_MICROS / STONE_GATHER_QUANTUM_MICROS) * STONE_GATHER_RATE_PER_QUANTUM, + canonicalSiteForId: canonicalStoneSiteV1ForId, + matchesCanonicalSite: matchesCanonicalTierIStoneSiteV1, + }), +}); + +export class CastleWorkerPolicyError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'CastleWorkerPolicyError'; + } +} + +function fail(code: string): never { + throw new CastleWorkerPolicyError(code); +} + +function assertU64(value: unknown, code: string): asserts value is bigint { + if (typeof value !== 'bigint' || value < 0n || value > CASTLE_WORKER_U64_MAX) fail(code); +} + +function checkedSum(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (right > CASTLE_WORKER_U64_MAX - left) fail(code); + return left + right; +} + +function checkedProduct(left: bigint, right: bigint, code: string): bigint { + assertU64(left, code); + assertU64(right, code); + if (left !== 0n && right > CASTLE_WORKER_U64_MAX / left) fail(code); + return left * right; +} + +export function workerResourcePolicy(kind: string): CastleWorkerResourcePolicy { + if (kind !== 'gold' && kind !== 'food' && kind !== 'wood' && kind !== 'stone') { + fail('WORKER_RESOURCE_UNSUPPORTED'); + } + return RESOURCE_POLICIES[kind]; +} + +export function workerResourceKinds(): readonly WorkerResourceKind[] { + return Object.freeze(['gold', 'food', 'wood', 'stone']); +} + +export function workerIdForCastle(castleId: bigint, ordinal: number): string { + if (castleId < 0n || !Number.isSafeInteger(ordinal) || ordinal < 1 || ordinal > CASTLE_WORKERS_PER_CASTLE) { + fail('WORKER_ROSTER_ORDINAL_INVALID'); + } + return `genesis-001-castle-${castleId.toString()}-worker-${String(ordinal).padStart(2, '0')}`; +} + +export function assertCastleWorkerId(workerId: string): void { + if (!/^genesis-001-castle-[0-9]+-worker-0[1-4]$/.test(workerId)) { + fail('WORKER_ID_INVALID'); + } +} + +export function assertWorkerCommandKey(value: string): void { + if (!/^[a-z0-9][a-z0-9-]{15,79}$/.test(value)) fail('WORKER_COMMAND_KEY_INVALID'); +} + +export type CastleWorkerTimeline = Readonly<{ + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnsAtMicros: bigint; +}>; + +export function planCastleWorkerTimeline(startedAtMicros: bigint, routeSteps: number): CastleWorkerTimeline { + assertU64(startedAtMicros, 'WORKER_START_TIME_INVALID'); + if (!Number.isSafeInteger(routeSteps) || routeSteps <= 0) fail('WORKER_ROUTE_INVALID'); + const travelMicros = checkedProduct(BigInt(routeSteps), CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, 'WORKER_TIME_OVERFLOW'); + const arrivesAtMicros = checkedSum(startedAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW'); + const gatheringEndsAtMicros = checkedSum(arrivesAtMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, 'WORKER_TIME_OVERFLOW'); + const returnsAtMicros = checkedSum(gatheringEndsAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW'); + return Object.freeze({ startedAtMicros, arrivesAtMicros, gatheringEndsAtMicros, returnsAtMicros }); +} + +export type CastleWorkerAccrualState = Readonly<{ + phase: string; + startedAtMicros: bigint; + arrivesAtMicros: bigint; + gatheringEndsAtMicros: bigint; + returnsAtMicros: bigint; + settledThroughMicros: bigint; + accruedAmount: bigint; + materializedAmount: bigint; + resourceKind: string; + policyVersion: string; +}>; + +export type CastleWorkerAccrualPlan = Readonly<{ + accruedAmount: bigint; + newlyAccruedAmount: bigint; + completedQuanta: bigint; + settledThroughMicros: bigint; +}>; + +export function workerAssignmentStateIsConsistent(state: CastleWorkerAccrualState): boolean { + try { + const policy = workerResourcePolicy(state.resourceKind); + assertU64(state.startedAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.arrivesAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.gatheringEndsAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.returnsAtMicros, 'WORKER_TIME_INVALID'); + assertU64(state.settledThroughMicros, 'WORKER_CURSOR_INVALID'); + assertU64(state.accruedAmount, 'WORKER_ACCRUAL_INVALID'); + assertU64(state.materializedAmount, 'WORKER_MATERIALIZED_INVALID'); + if ( + state.policyVersion !== CASTLE_WORKER_POLICY_VERSION + || (state.phase !== 'outbound' && state.phase !== 'gathering' && state.phase !== 'returning') + || !(state.startedAtMicros < state.arrivesAtMicros + && state.arrivesAtMicros < state.gatheringEndsAtMicros + && state.gatheringEndsAtMicros < state.returnsAtMicros) + || state.arrivesAtMicros > state.settledThroughMicros + || state.settledThroughMicros > state.gatheringEndsAtMicros + || state.materializedAmount > state.accruedAmount + || state.accruedAmount > policy.gatheringTotal + ) return false; + // A recall can begin during outbound travel, so a returning assignment may + // legitimately have zero or partial gathering accrual. Scheduled expiry + // still settles the full cap before opening the return timeline. + return true; + } catch { + return false; + } +} + +export function planCastleWorkerAccrual( + state: CastleWorkerAccrualState, + observedAtMicros: bigint, +): CastleWorkerAccrualPlan { + if (!workerAssignmentStateIsConsistent(state)) fail('WORKER_ASSIGNMENT_STATE_INVALID'); + assertU64(observedAtMicros, 'WORKER_OBSERVED_TIME_INVALID'); + const policy = workerResourcePolicy(state.resourceKind); + const ceiling = observedAtMicros < state.gatheringEndsAtMicros ? observedAtMicros : state.gatheringEndsAtMicros; + if (ceiling <= state.settledThroughMicros) { + return Object.freeze({ accruedAmount: state.accruedAmount, newlyAccruedAmount: 0n, completedQuanta: 0n, settledThroughMicros: state.settledThroughMicros }); + } + const completedQuanta = (ceiling - state.settledThroughMicros) / policy.quantumMicros; + const elapsed = checkedProduct(completedQuanta, policy.quantumMicros, 'WORKER_ACCRUAL_OVERFLOW'); + const settledThroughMicros = checkedSum(state.settledThroughMicros, elapsed, 'WORKER_ACCRUAL_OVERFLOW'); + const newlyAccruedAmount = checkedProduct(completedQuanta, policy.ratePerQuantum, 'WORKER_ACCRUAL_OVERFLOW'); + const accruedAmount = checkedSum(state.accruedAmount, newlyAccruedAmount, 'WORKER_ACCRUAL_OVERFLOW'); + if (accruedAmount > policy.gatheringTotal) fail('WORKER_ACCRUAL_CAP'); + return Object.freeze({ accruedAmount, newlyAccruedAmount, completedQuanta, settledThroughMicros }); +} + +/** Route authority is shared across all four canonical site catalogs. */ +export function canonicalWorkerRouteSteps( + origin: Readonly<{ q: number; r: number }>, + destination: Readonly<{ q: number; r: number }>, +): number | undefined { + return canonicalPassableRouteSteps(origin, destination); +} + +/** Stable roster digest; order and worker identity are part of the boundary. */ +export function rosterDigestForCastleIds(castleIds: readonly bigint[]): string { + const ids = [...castleIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + let hash = 0xcbf29ce484222325n; + for (const castleId of ids) { + for (const workerId of Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, i) => workerIdForCastle(castleId, i + 1))) { + for (const byte of new TextEncoder().encode(workerId)) { + hash ^= BigInt(byte); + hash = (hash * 0x100000001b3n) & CASTLE_WORKER_U64_MAX; + } + } + } + return hash.toString(16).padStart(16, '0'); +} diff --git a/spacetimedb/src/castleWorkerRoster.ts b/spacetimedb/src/castleWorkerRoster.ts new file mode 100644 index 00000000..29a6c0c7 --- /dev/null +++ b/spacetimedb/src/castleWorkerRoster.ts @@ -0,0 +1,118 @@ +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; + +import { + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + workerIdForCastle, + assertCastleWorkerId, +} from './castleWorkerPolicy'; +import type warpkeep from './schema'; + +type WarpkeepReducerContext = ReducerCtx>; +type CastleRow = NonNullable>; +type CastleWorkerRow = NonNullable>; + +function fail(code = 'WORKER_ROSTER_INTEGRITY'): never { + throw new Error(code); +} + +export function expectedWorkerRowsForCastle( + castle: Pick, + timestamp: WarpkeepReducerContext['timestamp'], +): readonly CastleWorkerRow[] { + return Object.freeze(Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => { + const ordinal = index + 1; + return Object.freeze({ + workerId: workerIdForCastle(castle.castleId, ordinal), + originCastleId: castle.castleId, + ordinal, + status: 'idle', + assignmentId: undefined, + resourceKind: undefined, + siteId: undefined, + startedAtMicros: undefined, + arrivesAtMicros: undefined, + gatheringEndsAtMicros: undefined, + returnStartedAtMicros: undefined, + returnsAtMicros: undefined, + routeSteps: undefined, + returnStartProgressBasisPoints: undefined, + timelineRevision: 0, + revision: 0n, + createdAt: timestamp, + updatedAt: timestamp, + }); + })); +} + +export function workerSystemRowIsStagedOrActive( + row: NonNullable>, +): boolean { + return row.realmId === 'GENESIS_001' + && row.policyVersion === CASTLE_WORKER_POLICY_VERSION + && row.workersPerCastle === CASTLE_WORKERS_PER_CASTLE + && (row.mode === 'staged' || row.mode === 'active') + && row.expectedCastleCount >= 0 + && row.expectedWorkerCount === row.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE; +} + +export function assertCastleWorkerRoster( + ctx: WarpkeepReducerContext, + castleId: bigint, +): readonly CastleWorkerRow[] { + const castle = ctx.db.castle.castleId.find(castleId); + if (castle === null) fail('WORKER_CASTLE_MISSING'); + const rows = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castleId)] + .sort((left, right) => left.ordinal - right.ordinal || left.workerId.localeCompare(right.workerId)); + if (rows.length !== CASTLE_WORKERS_PER_CASTLE) fail('WORKER_ROSTER_INCOMPLETE'); + const expectedIds = new Set(); + for (const row of rows) { + assertCastleWorkerId(row.workerId); + if ( + row.originCastleId !== castleId + || row.ordinal < 1 + || row.ordinal > CASTLE_WORKERS_PER_CASTLE + || expectedIds.has(row.workerId) + || row.workerId !== workerIdForCastle(castleId, row.ordinal) + || row.revision < 0n + || row.timelineRevision < 0 + ) fail('WORKER_ROSTER_INTEGRITY'); + expectedIds.add(row.workerId); + } + for (let ordinal = 1; ordinal <= CASTLE_WORKERS_PER_CASTLE; ordinal += 1) { + if (!expectedIds.has(workerIdForCastle(castleId, ordinal))) fail('WORKER_ROSTER_INTEGRITY'); + } + return Object.freeze(rows); +} + +/** + * Founding calls this only when generic mode is active. In staged mode the + * function is a no-op, so this PR cannot seed production workers accidentally. + */ +export function ensureCastleWorkerRoster( + ctx: WarpkeepReducerContext, + castle: CastleRow, +): void { + const system = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001'); + if (system === null) return; + if (!workerSystemRowIsStagedOrActive(system)) fail('WORKER_SYSTEM_INTEGRITY'); + if (system.mode !== 'active') return; + const existing = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)]; + if (existing.length > 0) { + assertCastleWorkerRoster(ctx, castle.castleId); + return; + } + for (const row of expectedWorkerRowsForCastle(castle, ctx.timestamp)) { + ctx.db.castleWorkerV1.insert(row); + } + assertCastleWorkerRoster(ctx, castle.castleId); +} + +export function workerRosterDigestInput(castleIds: readonly bigint[]): string { + return [...castleIds] + .sort((left, right) => left < right ? -1 : left > right ? 1 : 0) + .flatMap(castleId => Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => ( + workerIdForCastle(castleId, index + 1) + ))) + .join('|'); +} diff --git a/spacetimedb/src/foundingAuthority.ts b/spacetimedb/src/foundingAuthority.ts index bf979100..ea4e5188 100644 --- a/spacetimedb/src/foundingAuthority.ts +++ b/spacetimedb/src/foundingAuthority.ts @@ -14,6 +14,7 @@ import { GENESIS_RESOURCE_POLICY_VERSION, GENESIS_STARTING_RESOURCE_BALANCES, } from './resourceAuthorityPolicy'; +import { ensureCastleWorkerRoster } from './castleWorkerRoster'; import { admissionProfileIsComplete, trustedProfilesEqual, @@ -314,6 +315,11 @@ export function ensureGenesisFounder( updatedAt: ctx.timestamp, }); + // Generic workers are created only after a separately authorized active + // system row exists. The PR ships staged authority and therefore performs + // no production roster backfill or activation. + ensureCastleWorkerRoster(ctx, castle); + assertGenesisFoundingGraph(ctx); return 'created'; } diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 429d630a..b4b3c138 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -41,6 +41,15 @@ export { adminBackfillResourceAccountsV1, adminGetAlphaStatusV4, } from './reducers/resources'; +export { + getMyWorkerRosterV1, + getMyResourceStateV2, + dispatchWorkerV1, + recallWorkerV1, + recallAllWorkersV1, + adminGetWorkerSystemStatusV1, + adminPlanWorkerRosterV1, +} from './reducers/castleWorkers'; export { getMyGoldExpeditionStateV1, dispatchGoldExpeditionV1, @@ -83,4 +92,5 @@ export { runFoodExpeditionScheduleV1, runWoodExpeditionScheduleV1, runStoneExpeditionScheduleV1, + runCastleWorkerScheduleV1, } from './schema'; diff --git a/spacetimedb/src/reducers/castleWorkers.ts b/spacetimedb/src/reducers/castleWorkers.ts new file mode 100644 index 00000000..f55026f2 --- /dev/null +++ b/spacetimedb/src/reducers/castleWorkers.ts @@ -0,0 +1,270 @@ +import { SenderError, t } from 'spacetimedb/server'; + +import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { + castleWorkerErrorCode, + dispatchCastleWorker, + inspectCastleWorkerGraph, + projectMyWorkerState, + recallAllCastleWorkers, + recallCastleWorker, +} from '../castleWorkerAuthority'; +import warpkeep from '../schema'; + +const workerPrivate = t.object('WorkerPrivateV1', { + workerId: t.string(), + ordinal: t.u32(), + status: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + accruedAmount: t.u64(), + materializedAmount: t.u64(), + availableAmount: t.u64(), + observedAtMicros: t.u64(), + revision: t.u64(), +}); + +const myWorkerRoster = t.object('MyWorkerRosterV1', { + fid: t.u64(), + castleId: t.u64(), + observedAtMicros: t.u64(), + workers: t.array(workerPrivate), +}); + +const myResourceStateV2 = t.object('MyResourceStateV2', { + fid: t.u64(), + food: t.u64(), + wood: t.u64(), + stone: t.u64(), + gold: t.u64(), + workerPendingFood: t.u64(), + workerPendingWood: t.u64(), + workerPendingStone: t.u64(), + workerPendingGold: t.u64(), + observedAtMicros: t.u64(), + settledThroughMicros: t.u64(), + revision: t.u64(), + resourcePolicyVersion: t.string(), + workerPolicyVersion: t.string(), + workerSystemMode: t.string(), +}); + +const adminWorkerSystemStatus = t.object('AdminWorkerSystemStatusV1', { + systemRows: t.u64(), + mode: t.string(), + expectedCastleCount: t.u64(), + expectedWorkerCount: t.u64(), + actualWorkerCount: t.u64(), + castlesMissingWorkers: t.u64(), + castlesWithExtraWorkers: t.u64(), + duplicateOrdinals: t.u64(), + malformedWorkerIds: t.u64(), + idleWorkers: t.u64(), + outboundWorkers: t.u64(), + gatheringWorkers: t.u64(), + returningWorkers: t.u64(), + assignments: t.u64(), + occupations: t.u64(), + schedules: t.u64(), + orphanWorkers: t.u64(), + orphanAssignments: t.u64(), + orphanOccupations: t.u64(), + assignmentPublicMismatches: t.u64(), + occupationSiteMismatches: t.u64(), + legacyExpeditions: t.u64(), + legacyOccupations: t.u64(), + legacySchedules: t.u64(), + rosterDigest: t.string(), + rosterDigestExpected: t.string(), +}); + +const adminWorkerRosterPlan = t.object('AdminWorkerRosterPlanV1', { + ready: t.bool(), + activationBlockedByLegacyRows: t.bool(), + mode: t.string(), + expectedCastleCount: t.u64(), + expectedWorkerCount: t.u64(), + actualWorkerCount: t.u64(), + castlesMissingWorkers: t.u64(), + castlesWithExtraWorkers: t.u64(), + orphanWorkers: t.u64(), + orphanAssignments: t.u64(), + orphanOccupations: t.u64(), + assignmentPublicMismatches: t.u64(), + occupationSiteMismatches: t.u64(), + legacyExpeditions: t.u64(), + legacyOccupations: t.u64(), + legacySchedules: t.u64(), + rosterDigest: t.string(), + rosterDigestExpected: t.string(), +}); + +function senderPolicyError(error: unknown): never { + const code = castleWorkerErrorCode(error); + if (code !== undefined) throw new SenderError(code); + throw error; +} + +function workerSystemMode(ctx: Parameters[0]): string { + return ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001')?.mode ?? 'absent'; +} + +function aggregateResult(aggregate: ReturnType) { + return { ...aggregate }; +} + +export const getMyWorkerRosterV1 = warpkeep.procedure( + { name: 'get_my_worker_roster_v1' }, + myWorkerRoster, + ctx => ctx.withTx(tx => { + try { + const { claims, castle } = requireGameplayPlayerV1(tx); + const observedAtMicros = tx.timestamp.microsSinceUnixEpoch; + const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros); + return { + fid: claims.fid, + castleId: castle.castleId, + observedAtMicros, + workers: projection.workers.map(worker => ({ + workerId: worker.workerId, + ordinal: worker.ordinal, + status: worker.status, + resourceKind: worker.resourceKind, + siteId: worker.siteId, + accruedAmount: worker.accruedAmount, + materializedAmount: worker.materializedAmount, + availableAmount: worker.availableAmount, + observedAtMicros: worker.observedAtMicros, + revision: worker.revision, + })), + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +export const getMyResourceStateV2 = warpkeep.procedure( + { name: 'get_my_resource_state_v2' }, + myResourceStateV2, + ctx => ctx.withTx(tx => { + try { + const { claims } = requireGameplayPlayerV1(tx); + const observedAtMicros = tx.timestamp.microsSinceUnixEpoch; + const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros); + const pending = { food: 0n, wood: 0n, stone: 0n, gold: 0n }; + for (const worker of projection.workers) { + if (worker.resourceKind === 'food') pending.food += worker.availableAmount; + if (worker.resourceKind === 'wood') pending.wood += worker.availableAmount; + if (worker.resourceKind === 'stone') pending.stone += worker.availableAmount; + if (worker.resourceKind === 'gold') pending.gold += worker.availableAmount; + } + return { + fid: claims.fid, + food: projection.balances.food, + wood: projection.balances.wood, + stone: projection.balances.stone, + gold: projection.balances.gold, + workerPendingFood: pending.food, + workerPendingWood: pending.wood, + workerPendingStone: pending.stone, + workerPendingGold: pending.gold, + observedAtMicros, + settledThroughMicros: projection.resource.settledThroughMicros, + revision: projection.resource.revision, + resourcePolicyVersion: projection.resource.policyVersion, + workerPolicyVersion: 'genesis-001-castle-workers-v1', + workerSystemMode: workerSystemMode(tx), + }; + } catch (error) { + return senderPolicyError(error); + } + }), +); + +export const dispatchWorkerV1 = warpkeep.reducer( + { name: 'dispatch_worker_v1' }, + { workerId: t.string(), resourceKind: t.string(), siteId: t.string(), idempotencyKey: t.string() }, + (ctx, { workerId, resourceKind, siteId, idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + dispatchCastleWorker(ctx, { fid: claims.fid, castle, workerId, resourceKind, siteId, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const recallWorkerV1 = warpkeep.reducer( + { name: 'recall_worker_v1' }, + { workerId: t.string(), idempotencyKey: t.string() }, + (ctx, { workerId, idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + recallCastleWorker(ctx, { fid: claims.fid, castle, workerId, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const recallAllWorkersV1 = warpkeep.reducer( + { name: 'recall_all_workers_v1' }, + { idempotencyKey: t.string() }, + (ctx, { idempotencyKey }) => { + try { + const { claims, castle } = requireGameplayPlayerV1(ctx); + recallAllCastleWorkers(ctx, { fid: claims.fid, castle, idempotencyKey }); + } catch (error) { + return senderPolicyError(error); + } + }, +); + +export const adminGetWorkerSystemStatusV1 = warpkeep.procedure( + { name: 'admin_get_worker_system_status_v1' }, + adminWorkerSystemStatus, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + return aggregateResult(inspectCastleWorkerGraph(tx)); + }), +); + +export const adminPlanWorkerRosterV1 = warpkeep.procedure( + { name: 'admin_plan_worker_roster_v1' }, + adminWorkerRosterPlan, + ctx => ctx.withTx(tx => { + requireAdmin(tx); + const aggregate = inspectCastleWorkerGraph(tx); + const legacyRows = aggregate.legacyExpeditions + aggregate.legacyOccupations + aggregate.legacySchedules; + return { + ready: aggregate.systemRows === 1n + && aggregate.mode === 'active' + && aggregate.castlesMissingWorkers === 0n + && aggregate.castlesWithExtraWorkers === 0n + && aggregate.orphanWorkers === 0n + && aggregate.orphanAssignments === 0n + && aggregate.orphanOccupations === 0n + && aggregate.assignmentPublicMismatches === 0n + && aggregate.occupationSiteMismatches === 0n + && legacyRows === 0n, + activationBlockedByLegacyRows: legacyRows !== 0n, + mode: aggregate.mode, + expectedCastleCount: aggregate.expectedCastleCount, + expectedWorkerCount: aggregate.expectedWorkerCount, + actualWorkerCount: aggregate.actualWorkerCount, + castlesMissingWorkers: aggregate.castlesMissingWorkers, + castlesWithExtraWorkers: aggregate.castlesWithExtraWorkers, + orphanWorkers: aggregate.orphanWorkers, + orphanAssignments: aggregate.orphanAssignments, + orphanOccupations: aggregate.orphanOccupations, + assignmentPublicMismatches: aggregate.assignmentPublicMismatches, + occupationSiteMismatches: aggregate.occupationSiteMismatches, + legacyExpeditions: aggregate.legacyExpeditions, + legacyOccupations: aggregate.legacyOccupations, + legacySchedules: aggregate.legacySchedules, + rosterDigest: aggregate.rosterDigest, + rosterDigestExpected: aggregate.rosterDigestExpected, + }; + }), +); diff --git a/spacetimedb/src/reducers/resources.ts b/spacetimedb/src/reducers/resources.ts index 5c6393cc..3c4077af 100644 --- a/spacetimedb/src/reducers/resources.ts +++ b/spacetimedb/src/reducers/resources.ts @@ -2,6 +2,7 @@ import { SenderError, t } from 'spacetimedb/server'; import { WARPKEEP_BACKEND_PROTOCOL_VERSION } from '../config'; import { requireAdmin, requireGameplayPlayerV1 } from '../auth'; +import { castleWorkerErrorCode, settleAllWorkerAssignmentsForFid } from '../castleWorkerAuthority'; import { markAccountIsConsistent } from '../marksAuthorityPolicy'; import { ResourceAuthorityError, @@ -73,6 +74,8 @@ const adminAlphaStatusV4 = t.object('AdminAlphaStatusV4', { }); function senderPolicyError(error: unknown): never { + const workerCode = castleWorkerErrorCode(error); + if (workerCode !== undefined) throw new SenderError(workerCode); const foodExpeditionCode = foodExpeditionErrorCode(error); if (foodExpeditionCode !== undefined) throw new SenderError(foodExpeditionCode); const woodExpeditionCode = woodExpeditionErrorCode(error); @@ -165,6 +168,10 @@ export const collectResourcesV1 = warpkeep.reducer( collectActiveFoodExpedition(ctx, claims.fid); collectActiveWoodExpedition(ctx, claims.fid); collectActiveStoneExpedition(ctx, claims.fid); + // Generic workers settle into the same private inventory at this exact + // server timestamp. This keeps the legacy collect reducer compatible + // while new clients use get_my_resource_state_v2 for no-write reads. + settleAllWorkerAssignmentsForFid(ctx, claims.fid); const resourceAfterExpeditions = assertGenesisResourceForFid(ctx, claims.fid); const settlement = planResourceSettlementForActiveExpeditionReservations( ctx, diff --git a/spacetimedb/src/resourceExpeditionReservationAuthority.ts b/spacetimedb/src/resourceExpeditionReservationAuthority.ts index d1e7ae2a..c70355b1 100644 --- a/spacetimedb/src/resourceExpeditionReservationAuthority.ts +++ b/spacetimedb/src/resourceExpeditionReservationAuthority.ts @@ -1,9 +1,17 @@ import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { + workerResourcePolicy, +} from './castleWorkerPolicy'; + import { FOOD_GATHERING_TOTAL_FOOD, foodExpeditionStateIsConsistent, } from './foodExpeditionPolicy'; +import { + GOLD_GATHERING_TOTAL_GOLD, + goldExpeditionStateIsConsistent, +} from './goldExpeditionPolicy'; import { WOOD_GATHERING_TOTAL_WOOD, woodExpeditionStateIsConsistent, @@ -41,13 +49,14 @@ export type ActiveExpeditionResourceReservations = Readonly<{ food: bigint; wood: bigint; stone: bigint; + gold: bigint; }>; /** - * Return exact uncredited thirty-day awards for the caller's active Food, Wood, - * and Stone wagons. A returning row has already credited its whole award and - * thus reserves zero. Independent tables permit one wagon of each resource - * type. + * Return exact uncredited thirty-day awards for every active legacy wagon and + * generic assignment. A returning row has already credited its whole award and + * thus reserves zero. Independent tables permit one legacy wagon of each + * resource type while generic workers add their own private reservations. */ export function activeExpeditionResourceReservations( ctx: WarpkeepReducerContext, @@ -65,11 +74,29 @@ export function activeExpeditionResourceReservations( if (stone !== null && !stoneExpeditionStateIsConsistent(stone)) { fail('STONE_EXPEDITION_RESERVATION_STATE_INVALID'); } - return Object.freeze({ - food: food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood, - wood: wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood, - stone: stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone, - }); + const gold = ctx.db.goldExpeditionV1.fid.find(fid); + if (gold !== null && !goldExpeditionStateIsConsistent(gold)) { + fail('GOLD_EXPEDITION_RESERVATION_STATE_INVALID'); + } + let foodReservation = food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood; + let woodReservation = wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood; + let stoneReservation = stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone; + let goldReservation = gold === null ? 0n : GOLD_GATHERING_TOTAL_GOLD - gold.creditedGold; + for (const assignment of ctx.db.workerAssignmentV1.iter()) { + if (assignment.fid !== fid) continue; + if (assignment.phase === 'returning') continue; + const total = workerResourcePolicy(assignment.resourceKind).gatheringTotal; + // Reserve the complete remaining award, not only the currently accrued + // amount. This leaves room for lazy server-time settlement to materialize + // the exact future output without truncation. + const fullRemaining = total - assignment.materializedAmount; + if (fullRemaining < 0n) throw new ResourceExpeditionReservationAuthorityError('WORKER_RESERVATION_INVALID'); + if (assignment.resourceKind === 'food') foodReservation += fullRemaining; + if (assignment.resourceKind === 'wood') woodReservation += fullRemaining; + if (assignment.resourceKind === 'stone') stoneReservation += fullRemaining; + if (assignment.resourceKind === 'gold') goldReservation += fullRemaining; + } + return Object.freeze({ food: foodReservation, wood: woodReservation, stone: stoneReservation, gold: goldReservation }); } /** diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts index 92f17dde..e9664761 100644 --- a/spacetimedb/src/schema.ts +++ b/spacetimedb/src/schema.ts @@ -16,6 +16,10 @@ import { runStoneExpeditionSchedule, stoneExpeditionErrorCode, } from './stoneExpeditionAuthority'; +import { + castleWorkerErrorCode, + runCastleWorkerSchedule, +} from './castleWorkerAuthority'; /** * Private closed-alpha admission list. This table is intentionally omitted @@ -1001,6 +1005,171 @@ export const realmWaterRevisionV1 = table( activatedAt: t.option(t.timestamp()), }, ); + +/** Public singleton for the staged generic-worker readiness boundary. */ +export const realmWorkerSystemV1 = table( + { name: 'realm_worker_system_v1', public: true }, + { + realmId: t.string().primaryKey(), + policyVersion: t.string(), + workersPerCastle: t.u32(), + expectedCastleCount: t.u32(), + expectedWorkerCount: t.u32(), + rosterDigest: t.string(), + mode: t.string(), + legacyDrainRequired: t.bool(), + createdAt: t.timestamp(), + activatedAt: t.option(t.timestamp()), + }, +); + +/** Public identity-safe worker roster and lifecycle presentation. */ +export const castleWorkerV1 = table( + { + name: 'castle_worker_v1', + public: true, + indexes: [{ + accessor: 'byOriginCastle', + algorithm: 'btree', + columns: ['originCastleId'] as const, + }] as const, + }, + { + workerId: t.string().primaryKey(), + originCastleId: t.u64(), + ordinal: t.u32(), + status: t.string(), + assignmentId: t.option(t.string()), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + startedAtMicros: t.option(t.u64()), + arrivesAtMicros: t.option(t.u64()), + gatheringEndsAtMicros: t.option(t.u64()), + returnStartedAtMicros: t.option(t.u64()), + returnsAtMicros: t.option(t.u64()), + routeSteps: t.option(t.u32()), + returnStartProgressBasisPoints: t.option(t.u32()), + timelineRevision: t.u32(), + revision: t.u64(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** Private generic assignment authority. Never expose through subscriptions. */ +export const workerAssignmentV1 = table( + { + name: 'worker_assignment_v1', + indexes: [{ + accessor: 'byFidAndPhase', + algorithm: 'btree', + columns: ['fid', 'phase'] as const, + }] as const, + }, + { + assignmentId: t.string().primaryKey(), + workerId: t.string().unique(), + fid: t.u64(), + originCastleId: t.u64(), + resourceKind: t.string(), + siteId: t.string().index(), + phase: t.string(), + startedAtMicros: t.u64(), + arrivesAtMicros: t.u64(), + gatheringEndsAtMicros: t.u64(), + returnStartedAtMicros: t.option(t.u64()), + returnsAtMicros: t.u64(), + routeSteps: t.u32(), + returnStartProgressBasisPoints: t.u32(), + settledThroughMicros: t.u64(), + accruedAmount: t.u64(), + materializedAmount: t.u64(), + timelineRevision: t.u32(), + policyVersion: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }, +); + +/** Public generic node lease. It is deleted when a worker starts returning. */ +export const workerNodeOccupationV1 = table( + { + name: 'worker_node_occupation_v1', + public: true, + indexes: [{ + accessor: 'byOriginCastle', + algorithm: 'btree', + columns: ['originCastleId'] as const, + }, { + accessor: 'byWorker', + algorithm: 'btree', + columns: ['workerId'] as const, + }] as const, + }, + { + nodeKey: t.string().primaryKey(), + resourceKind: t.string(), + siteId: t.string(), + workerId: t.string(), + workerOrdinal: t.u32(), + originCastleId: t.u64(), + assignmentId: t.string(), + phase: t.string(), + startedAtMicros: t.u64(), + arrivesAtMicros: t.u64(), + gatheringEndsAtMicros: t.u64(), + timelineRevision: t.u32(), + }, +); + +/** Private exactly-once command receipts for dispatch and recall commands. */ +export const workerCommandIdempotencyV1 = table( + { + name: 'worker_command_idempotency_v1', + indexes: [{ + accessor: 'byFid', + algorithm: 'btree', + columns: ['fid'] as const, + }] as const, + }, + { + requestKey: t.string().primaryKey(), + fid: t.u64(), + workerId: t.option(t.string()), + commandKind: t.string(), + resourceKind: t.option(t.string()), + siteId: t.option(t.string()), + assignmentId: t.option(t.string()), + resultRevision: t.u64(), + createdAt: t.timestamp(), + }, +); + +/** Public-safe lifecycle schedule projection for generic worker assignments. */ +export const workerAssignmentScheduleV1 = table( + { + name: 'worker_assignment_schedule_v_1', + public: true, + indexes: [{ + accessor: 'byAssignment', + algorithm: 'btree', + columns: ['assignmentId'] as const, + }, { + accessor: 'byWorker', + algorithm: 'btree', + columns: ['workerId'] as const, + }] as const, + scheduled: (): any => runCastleWorkerScheduleV1, + }, + { + scheduleId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + assignmentId: t.string(), + workerId: t.string(), + timelineRevision: t.u32(), + stage: t.string(), + }, +); const warpkeep = schema({ // Preserve the original production schema prefix exactly. New tables are // append-only so SpacetimeDB can apply this migration without rewriting it. @@ -1051,6 +1220,12 @@ const warpkeep = schema({ stoneExpeditionIdempotencyV1, stoneExpeditionScheduleV1, realmWaterRevisionV1, + realmWorkerSystemV1, + castleWorkerV1, + workerAssignmentV1, + workerNodeOccupationV1, + workerCommandIdempotencyV1, + workerAssignmentScheduleV1, }); /** @@ -1124,6 +1299,21 @@ export const runStoneExpeditionScheduleV1 = warpkeep.reducer( }, ); +/** Scheduler-only lifecycle reducer for staged generic castle workers. */ +export const runCastleWorkerScheduleV1 = warpkeep.reducer( + { name: 'run_worker_assignment_schedule_v_1' }, + { arg: workerAssignmentScheduleV1.rowType }, + (ctx, { arg }) => { + try { + runCastleWorkerSchedule(ctx, arg); + } catch (error) { + const code = castleWorkerErrorCode(error); + if (code !== undefined) throw new SenderError(code); + throw error; + } + }, +); + // SpacetimeDB 2.6's default case converter separates a trailing digit from // its prefix (`v2` -> `v_2`). Pin every versioned wire spelling explicitly. for (const name of [ @@ -1168,6 +1358,13 @@ for (const name of [ 'admin_seed_genesis_water_layout_v1', 'admin_activate_genesis_water_layout_v1', 'admin_inspect_genesis_water_layout_v1', + 'get_my_worker_roster_v1', + 'get_my_resource_state_v2', + 'dispatch_worker_v1', + 'recall_worker_v1', + 'recall_all_workers_v1', + 'admin_get_worker_system_status_v1', + 'admin_plan_worker_roster_v1', ]) { warpkeep.moduleDef.explicitNames.entries.push({ tag: 'Function', diff --git a/spacetimedb/tests/castleWorkerMigrationTooling.test.ts b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts new file mode 100644 index 00000000..453dd975 --- /dev/null +++ b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), 'utf8'); +} + +function schemaRegistrations(text: string, marker: string): string[] { + const start = text.indexOf(marker); + const end = text.indexOf('\n);', start); + assert.ok(start >= 0 && end > start); + return text.slice(start + marker.length, end) + .split(/[,\n]/) + .map(value => value.trim()) + .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value)); +} + +function tableDefinition(text: string, name: string): string { + const start = text.indexOf(`const ${name} = table(`); + const end = text.indexOf('\n);', start); + assert.ok(start >= 0 && end > start); + return text.slice(start, end); +} + +test('v12 fixture appends six generic-worker tables after the exact v11 prefix', () => { + const v11 = source('../migration-fixtures/additive-v11-schema/src/index.ts'); + const v12 = source('../migration-fixtures/additive-v12-schema/src/index.ts'); + const v11Registrations = schemaRegistrations(v11, 'const db = schema({'); + const v12Registrations = schemaRegistrations(v12, 'const db = schema({'); + assert.equal(v11Registrations.length, 47); + assert.deepEqual(v12Registrations.slice(0, 47), v11Registrations); + assert.deepEqual(v12Registrations.slice(47), [ + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', + ]); + assert.match(v12, /fixture_seed_generic_worker_sentinel_v12/); + assert.match(source('../migration-fixtures/additive-v12-schema/package.json'), /additive-v12-schema/); +}); + +test('public generic-worker rows exclude private ownership and accrual fields', () => { + const schema = source('../src/schema.ts'); + for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1', 'workerAssignmentScheduleV1']) { + const definition = tableDefinition(schema, name); + assert.match(definition, /public: true/); + assert.doesNotMatch(definition, /\bfid\b|accruedAmount|materializedAmount|balance|requestKey|auth/i); + } + const assignment = tableDefinition(schema, 'workerAssignmentV1'); + const idempotency = tableDefinition(schema, 'workerCommandIdempotencyV1'); + assert.doesNotMatch(assignment, /public: true/); + assert.doesNotMatch(idempotency, /public: true/); + assert.match(assignment, /fid: t\.u64\(\)/); + assert.match(assignment, /accruedAmount: t\.u64\(\)/); + assert.match(idempotency, /requestKey: t\.string\(\)\.primaryKey\(\)/); +}); + +test('worker reducers are caller-bound and activation remains explicitly gated', () => { + const reducers = source('../src/reducers/castleWorkers.ts'); + const authority = source('../src/castleWorkerAuthority.ts'); + assert.match(reducers, /name: 'dispatch_worker_v1'/); + assert.match(reducers, /requireGameplayPlayerV1\(ctx\)/); + assert.match(reducers, /dispatchCastleWorker\(ctx, \{ fid: claims\.fid, castle/); + assert.match(reducers, /name: 'recall_all_workers_v1'/); + assert.match(reducers, /name: 'admin_plan_worker_roster_v1'/); + assert.match(authority, /if \(row\.mode !== 'active'\) fail\('WORKER_SYSTEM_STAGED'\)/); + assert.match(authority, /legacy\.expeditions !== 0n \|\| legacy\.occupations !== 0n \|\| legacy\.schedules !== 0n/); + assert.match(authority, /workerNodeOccupationV1\.nodeKey\.delete\(occupation\.nodeKey\)/); + assert.match(authority, /planCastleWorkerAccrual\(assignment, observedAtMicros\)/); + assert.match(authority, /No[\s\S]{0,20}per-minute writes/); +}); diff --git a/spacetimedb/tests/castleWorkerPolicy.test.ts b/spacetimedb/tests/castleWorkerPolicy.test.ts new file mode 100644 index 00000000..e9865438 --- /dev/null +++ b/spacetimedb/tests/castleWorkerPolicy.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, + CASTLE_WORKER_POLICY_VERSION, + CASTLE_WORKERS_PER_CASTLE, + CastleWorkerPolicyError, + planCastleWorkerAccrual, + planCastleWorkerTimeline, + rosterDigestForCastleIds, + workerIdForCastle, + workerResourceKinds, + workerResourcePolicy, +} from '../src/castleWorkerPolicy'; + +test('generic worker roster IDs are stable and exactly four per castle', () => { + assert.equal(CASTLE_WORKERS_PER_CASTLE, 4); + assert.deepEqual( + Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => workerIdForCastle(42n, index + 1)), + [ + 'genesis-001-castle-42-worker-01', + 'genesis-001-castle-42-worker-02', + 'genesis-001-castle-42-worker-03', + 'genesis-001-castle-42-worker-04', + ], + ); + assert.notEqual(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([42n])); + assert.equal(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([7n, 42n])); +}); + +test('all four resource policies use the shared 60-second quantum and 30-day cap', () => { + assert.deepEqual(workerResourceKinds(), ['gold', 'food', 'wood', 'stone']); + for (const kind of workerResourceKinds()) { + const policy = workerResourcePolicy(kind); + assert.equal(policy.quantumMicros, 60_000_000n); + assert.equal(policy.gatheringDurationMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS); + assert.equal(policy.gatheringTotal, 43_200n * policy.ratePerQuantum); + } +}); + +test('timeline and accrual are server-time-only and quantum aligned', () => { + const timeline = planCastleWorkerTimeline(1_000_000n, 3); + assert.equal(timeline.arrivesAtMicros, 91_000_000n); + assert.equal(timeline.gatheringEndsAtMicros, 2_592_091_000_000n); + assert.equal(timeline.returnsAtMicros, 2_592_181_000_000n); + const policy = workerResourcePolicy('stone'); + const state = { + phase: 'gathering', + ...timeline, + settledThroughMicros: timeline.arrivesAtMicros, + accruedAmount: 0n, + materializedAmount: 0n, + resourceKind: 'stone', + policyVersion: CASTLE_WORKER_POLICY_VERSION, + } as const; + const plan = planCastleWorkerAccrual(state, timeline.arrivesAtMicros + 2n * policy.quantumMicros + 1n); + assert.equal(plan.completedQuanta, 2n); + assert.equal(plan.newlyAccruedAmount, 2n * policy.ratePerQuantum); + assert.equal(plan.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros); +}); + +test('policy rejects invalid resource kinds, roster ordinals, and routes', () => { + assert.throws(() => workerResourcePolicy('mana'), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_RESOURCE_UNSUPPORTED' + )); + assert.throws(() => workerIdForCastle(1n, 5), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROSTER_ORDINAL_INVALID' + )); + assert.throws(() => planCastleWorkerTimeline(0n, 0), (error: unknown) => ( + error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROUTE_INVALID' + )); +}); diff --git a/spacetimedb/tests/foodExpeditionReducers.test.ts b/spacetimedb/tests/foodExpeditionReducers.test.ts index dc88f138..101d6024 100644 --- a/spacetimedb/tests/foodExpeditionReducers.test.ts +++ b/spacetimedb/tests/foodExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v7 Food tables remain intact through later additive suffixes', () => { const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/spacetimedb/tests/goldExpeditionReducers.test.ts b/spacetimedb/tests/goldExpeditionReducers.test.ts index 86218d53..0c9e3de9 100644 --- a/spacetimedb/tests/goldExpeditionReducers.test.ts +++ b/spacetimedb/tests/goldExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes', const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', @@ -69,7 +69,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes', 'stoneExpeditionScheduleV1', 'realmWaterRevisionV1', ]); - assert.deepEqual(registrations.slice(-27, -22), [ + assert.deepEqual(registrations.slice(-33, -28), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts index 464485c1..b55ddbb8 100644 --- a/spacetimedb/tests/playerIdentityPrivacy.test.ts +++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts @@ -128,6 +128,7 @@ test('generated bindings contain the public projections and omit every private e const publicTableFiles = [ 'castle_slot_v_1_table.ts', 'castle_table.ts', + 'castle_worker_v_1_table.ts', 'food_expedition_schedule_v_1_table.ts', 'food_node_occupation_v_1_table.ts', 'food_site_v_1_table.ts', @@ -145,12 +146,15 @@ test('generated bindings contain the public projections and omit every private e 'realm_water_cell_v_1_table.ts', 'realm_water_layout_v_1_table.ts', 'realm_water_revision_v_1_table.ts', + 'realm_worker_system_v_1_table.ts', 'stone_expedition_schedule_v_1_table.ts', 'stone_node_occupation_v_1_table.ts', 'stone_site_v_1_table.ts', 'wood_expedition_schedule_v_1_table.ts', 'wood_node_occupation_v_1_table.ts', 'wood_site_v_1_table.ts', + 'worker_assignment_schedule_v_1_table.ts', + 'worker_node_occupation_v_1_table.ts', 'world_tile_meta_v_1_table.ts', 'world_tile_table.ts', ]; diff --git a/spacetimedb/tests/resourceReducers.test.ts b/spacetimedb/tests/resourceReducers.test.ts index 67308336..b5ae9377 100644 --- a/spacetimedb/tests/resourceReducers.test.ts +++ b/spacetimedb/tests/resourceReducers.test.ts @@ -78,6 +78,12 @@ test('resource and Gold prefixes remain intact through later additive suffixes', 'stoneExpeditionIdempotencyV1', 'stoneExpeditionScheduleV1', 'realmWaterRevisionV1', + 'realmWorkerSystemV1', + 'castleWorkerV1', + 'workerAssignmentV1', + 'workerNodeOccupationV1', + 'workerCommandIdempotencyV1', + 'workerAssignmentScheduleV1', ]); const account = tableDefinition(schema, 'resourceAccountV1'); diff --git a/spacetimedb/tests/stoneExpeditionReducers.test.ts b/spacetimedb/tests/stoneExpeditionReducers.test.ts index 9810701a..ed68b295 100644 --- a/spacetimedb/tests/stoneExpeditionReducers.test.ts +++ b/spacetimedb/tests/stoneExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v10 Stone tables remain before the additive Water revision suffix', () => const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-10), [ + assert.deepEqual(registrations.slice(-16, -6), [ 'realmWaterLayoutV1', 'realmWaterBodyV1', 'realmWaterCellV1', diff --git a/spacetimedb/tests/waterRevisionAuthority.test.ts b/spacetimedb/tests/waterRevisionAuthority.test.ts index 0a800f33..83a52c0c 100644 --- a/spacetimedb/tests/waterRevisionAuthority.test.ts +++ b/spacetimedb/tests/waterRevisionAuthority.test.ts @@ -62,5 +62,5 @@ test('the append-only public revision table stores policy without topology', () assert.match(revision, /navigationFogBoundaryDepthCells: t\.u32\(\)/); assert.match(revision, /activatedAt: t\.option\(t\.timestamp\(\)\)/); assert.doesNotMatch(revision, /\n\s*q:|\n\s*r:|cellKey:|bodyId:/); - assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\}\);/); + assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\}\);/); }); diff --git a/spacetimedb/tests/woodExpeditionReducers.test.ts b/spacetimedb/tests/woodExpeditionReducers.test.ts index 71109bf0..42ed2b47 100644 --- a/spacetimedb/tests/woodExpeditionReducers.test.ts +++ b/spacetimedb/tests/woodExpeditionReducers.test.ts @@ -40,7 +40,7 @@ test('v8 Wood tables remain intact through later additive suffixes', () => { const registrations = schemaRegistrations(schema); const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({')); assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations); - assert.deepEqual(registrations.slice(-27), [ + assert.deepEqual(registrations.slice(-33, -6), [ 'goldSiteV1', 'goldNodeOccupationV1', 'goldExpeditionV1', diff --git a/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts b/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts new file mode 100644 index 00000000..3ca8b3fc --- /dev/null +++ b/src/spacetime/module_bindings/admin_get_worker_system_status_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminWorkerSystemStatusV1, +} from "./types"; + +export const params = { +}; +export const returnType = AdminWorkerSystemStatusV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts b/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts new file mode 100644 index 00000000..5ebf2110 --- /dev/null +++ b/src/spacetime/module_bindings/admin_plan_worker_roster_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AdminWorkerRosterPlanV1, +} from "./types"; + +export const params = { +}; +export const returnType = AdminWorkerRosterPlanV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/castle_worker_v_1_table.ts b/src/spacetime/module_bindings/castle_worker_v_1_table.ts new file mode 100644 index 00000000..88a4efcc --- /dev/null +++ b/src/spacetime/module_bindings/castle_worker_v_1_table.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workerId: __t.string().primaryKey().name("worker_id"), + originCastleId: __t.u64().name("origin_castle_id"), + ordinal: __t.u32(), + status: __t.string(), + assignmentId: __t.option(__t.string()).name("assignment_id"), + resourceKind: __t.option(__t.string()).name("resource_kind"), + siteId: __t.option(__t.string()).name("site_id"), + startedAtMicros: __t.option(__t.u64()).name("started_at_micros"), + arrivesAtMicros: __t.option(__t.u64()).name("arrives_at_micros"), + gatheringEndsAtMicros: __t.option(__t.u64()).name("gathering_ends_at_micros"), + returnStartedAtMicros: __t.option(__t.u64()).name("return_started_at_micros"), + returnsAtMicros: __t.option(__t.u64()).name("returns_at_micros"), + routeSteps: __t.option(__t.u32()).name("route_steps"), + returnStartProgressBasisPoints: __t.option(__t.u32()).name("return_start_progress_basis_points"), + timelineRevision: __t.u32().name("timeline_revision"), + revision: __t.u64(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts b/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts new file mode 100644 index 00000000..6aa02ee2 --- /dev/null +++ b/src/spacetime/module_bindings/dispatch_worker_v_1_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workerId: __t.string(), + resourceKind: __t.string(), + siteId: __t.string(), + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts b/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts new file mode 100644 index 00000000..d5ad5da5 --- /dev/null +++ b/src/spacetime/module_bindings/get_my_resource_state_v_2_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MyResourceStateV2, +} from "./types"; + +export const params = { +}; +export const returnType = MyResourceStateV2 \ No newline at end of file diff --git a/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts b/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts new file mode 100644 index 00000000..d64bde64 --- /dev/null +++ b/src/spacetime/module_bindings/get_my_worker_roster_v_1_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MyWorkerRosterV1, +} from "./types"; + +export const params = { +}; +export const returnType = MyWorkerRosterV1 \ No newline at end of file diff --git a/src/spacetime/module_bindings/index.ts b/src/spacetime/module_bindings/index.ts index 93cd2e51..65862c62 100644 --- a/src/spacetime/module_bindings/index.ts +++ b/src/spacetime/module_bindings/index.ts @@ -68,6 +68,9 @@ import DispatchFoodExpeditionV1Reducer from "./dispatch_food_expedition_v_1_redu import DispatchGoldExpeditionV1Reducer from "./dispatch_gold_expedition_v_1_reducer"; import DispatchStoneExpeditionV1Reducer from "./dispatch_stone_expedition_v_1_reducer"; import DispatchWoodExpeditionV1Reducer from "./dispatch_wood_expedition_v_1_reducer"; +import DispatchWorkerV1Reducer from "./dispatch_worker_v_1_reducer"; +import RecallAllWorkersV1Reducer from "./recall_all_workers_v_1_reducer"; +import RecallWorkerV1Reducer from "./recall_worker_v_1_reducer"; // Import all procedure arg schemas import * as AdminGetAlphaStatusProcedure from "./admin_get_alpha_status_procedure"; @@ -78,8 +81,10 @@ import * as AdminGetAlphaStatusV8Procedure from "./admin_get_alpha_status_v_8_pr import * as AdminGetAlphaStatusV10Procedure from "./admin_get_alpha_status_v_10_procedure"; import * as AdminGetFidAuthEpochProcedure from "./admin_get_fid_auth_epoch_procedure"; import * as AdminGetSnapScanBatchAggregateV1Procedure from "./admin_get_snap_scan_batch_aggregate_v_1_procedure"; +import * as AdminGetWorkerSystemStatusV1Procedure from "./admin_get_worker_system_status_v_1_procedure"; import * as AdminInspectGenesisWaterLayoutV1Procedure from "./admin_inspect_genesis_water_layout_v_1_procedure"; import * as AdminInspectGenesisWaterRevisionV1Procedure from "./admin_inspect_genesis_water_revision_v_1_procedure"; +import * as AdminPlanWorkerRosterV1Procedure from "./admin_plan_worker_roster_v_1_procedure"; import * as AuthResolverGetFidAdmissionV2Procedure from "./auth_resolver_get_fid_admission_v_2_procedure"; import * as GetAlphaBackendInfoProcedure from "./get_alpha_backend_info_procedure"; import * as GetMyAdmissionStatusProcedure from "./get_my_admission_status_procedure"; @@ -87,14 +92,17 @@ import * as GetMyAdmissionStatusV2Procedure from "./get_my_admission_status_v_2_ import * as GetMyFoodExpeditionStateV1Procedure from "./get_my_food_expedition_state_v_1_procedure"; import * as GetMyGoldExpeditionStateV1Procedure from "./get_my_gold_expedition_state_v_1_procedure"; import * as GetMyResourceStateV1Procedure from "./get_my_resource_state_v_1_procedure"; +import * as GetMyResourceStateV2Procedure from "./get_my_resource_state_v_2_procedure"; import * as GetMyStoneExpeditionStateV1Procedure from "./get_my_stone_expedition_state_v_1_procedure"; import * as GetMyWoodExpeditionStateV1Procedure from "./get_my_wood_expedition_state_v_1_procedure"; +import * as GetMyWorkerRosterV1Procedure from "./get_my_worker_roster_v_1_procedure"; import * as QaObserverGetRealmAttestationV2Procedure from "./qa_observer_get_realm_attestation_v_2_procedure"; import * as QaObserverGetRealmSnapshotV1Procedure from "./qa_observer_get_realm_snapshot_v_1_procedure"; // Import all table schema definitions import CastleRow from "./castle_table"; import CastleSlotV1Row from "./castle_slot_v_1_table"; +import CastleWorkerV1Row from "./castle_worker_v_1_table"; import FoodExpeditionScheduleV1Row from "./food_expedition_schedule_v_1_table"; import FoodNodeOccupationV1Row from "./food_node_occupation_v_1_table"; import FoodSiteV1Row from "./food_site_v_1_table"; @@ -112,12 +120,15 @@ import RealmWaterBodyV1Row from "./realm_water_body_v_1_table"; import RealmWaterCellV1Row from "./realm_water_cell_v_1_table"; import RealmWaterLayoutV1Row from "./realm_water_layout_v_1_table"; import RealmWaterRevisionV1Row from "./realm_water_revision_v_1_table"; +import RealmWorkerSystemV1Row from "./realm_worker_system_v_1_table"; import StoneExpeditionScheduleV1Row from "./stone_expedition_schedule_v_1_table"; import StoneNodeOccupationV1Row from "./stone_node_occupation_v_1_table"; import StoneSiteV1Row from "./stone_site_v_1_table"; import WoodExpeditionScheduleV1Row from "./wood_expedition_schedule_v_1_table"; import WoodNodeOccupationV1Row from "./wood_node_occupation_v_1_table"; import WoodSiteV1Row from "./wood_site_v_1_table"; +import WorkerAssignmentScheduleV1Row from "./worker_assignment_schedule_v_1_table"; +import WorkerNodeOccupationV1Row from "./worker_node_occupation_v_1_table"; import WorldTileRow from "./world_tile_table"; import WorldTileMetaV1Row from "./world_tile_meta_v_1_table"; @@ -162,6 +173,20 @@ const tablesSchema = __schema({ { name: 'castle_slot_v1_tile_key_key', constraint: 'unique', columns: ['tileKey'] }, ], }, CastleSlotV1Row), + castleWorkerV1: __table({ + name: 'castle_worker_v1', + indexes: [ + { accessor: 'byOriginCastle', name: 'castle_worker_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: [ + 'originCastleId', + ] }, + { accessor: 'workerId', name: 'castle_worker_v1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'castle_worker_v1_worker_id_key', constraint: 'unique', columns: ['workerId'] }, + ], + }, CastleWorkerV1Row), foodExpeditionScheduleV1: __table({ name: 'food_expedition_schedule_v_1', indexes: [ @@ -391,6 +416,17 @@ const tablesSchema = __schema({ { name: 'realm_water_revision_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, ], }, RealmWaterRevisionV1Row), + realmWorkerSystemV1: __table({ + name: 'realm_worker_system_v1', + indexes: [ + { accessor: 'realmId', name: 'realm_worker_system_v1_realm_id_idx_btree', algorithm: 'btree', columns: [ + 'realmId', + ] }, + ], + constraints: [ + { name: 'realm_worker_system_v1_realm_id_key', constraint: 'unique', columns: ['realmId'] }, + ], + }, RealmWorkerSystemV1Row), stoneExpeditionScheduleV1: __table({ name: 'stone_expedition_schedule_v_1', indexes: [ @@ -475,6 +511,40 @@ const tablesSchema = __schema({ { name: 'wood_site_v1_site_id_key', constraint: 'unique', columns: ['siteId'] }, ], }, WoodSiteV1Row), + workerAssignmentScheduleV1: __table({ + name: 'worker_assignment_schedule_v_1', + indexes: [ + { accessor: 'byAssignment', name: 'worker_assignment_schedule_v_1_assignment_id_idx_btree', algorithm: 'btree', columns: [ + 'assignmentId', + ] }, + { accessor: 'scheduleId', name: 'worker_assignment_schedule_v_1_schedule_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduleId', + ] }, + { accessor: 'byWorker', name: 'worker_assignment_schedule_v_1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'worker_assignment_schedule_v_1_schedule_id_key', constraint: 'unique', columns: ['scheduleId'] }, + ], + }, WorkerAssignmentScheduleV1Row), + workerNodeOccupationV1: __table({ + name: 'worker_node_occupation_v1', + indexes: [ + { accessor: 'nodeKey', name: 'worker_node_occupation_v1_node_key_idx_btree', algorithm: 'btree', columns: [ + 'nodeKey', + ] }, + { accessor: 'byOriginCastle', name: 'worker_node_occupation_v1_origin_castle_id_idx_btree', algorithm: 'btree', columns: [ + 'originCastleId', + ] }, + { accessor: 'byWorker', name: 'worker_node_occupation_v1_worker_id_idx_btree', algorithm: 'btree', columns: [ + 'workerId', + ] }, + ], + constraints: [ + { name: 'worker_node_occupation_v1_node_key_key', constraint: 'unique', columns: ['nodeKey'] }, + ], + }, WorkerNodeOccupationV1Row), worldTile: __table({ name: 'world_tile', indexes: [ @@ -542,6 +612,9 @@ const reducersSchema = __reducers( __reducerSchema("dispatch_gold_expedition_v1", DispatchGoldExpeditionV1Reducer), __reducerSchema("dispatch_stone_expedition_v1", DispatchStoneExpeditionV1Reducer), __reducerSchema("dispatch_wood_expedition_v1", DispatchWoodExpeditionV1Reducer), + __reducerSchema("dispatch_worker_v1", DispatchWorkerV1Reducer), + __reducerSchema("recall_all_workers_v1", RecallAllWorkersV1Reducer), + __reducerSchema("recall_worker_v1", RecallWorkerV1Reducer), ); /** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ @@ -554,8 +627,10 @@ const proceduresSchema = __procedures( __procedureSchema("admin_get_alpha_status_v_10", AdminGetAlphaStatusV10Procedure.params, AdminGetAlphaStatusV10Procedure.returnType), __procedureSchema("admin_get_fid_auth_epoch", AdminGetFidAuthEpochProcedure.params, AdminGetFidAuthEpochProcedure.returnType), __procedureSchema("admin_get_snap_scan_batch_aggregate_v1", AdminGetSnapScanBatchAggregateV1Procedure.params, AdminGetSnapScanBatchAggregateV1Procedure.returnType), + __procedureSchema("admin_get_worker_system_status_v1", AdminGetWorkerSystemStatusV1Procedure.params, AdminGetWorkerSystemStatusV1Procedure.returnType), __procedureSchema("admin_inspect_genesis_water_layout_v1", AdminInspectGenesisWaterLayoutV1Procedure.params, AdminInspectGenesisWaterLayoutV1Procedure.returnType), __procedureSchema("admin_inspect_genesis_water_revision_v_1", AdminInspectGenesisWaterRevisionV1Procedure.params, AdminInspectGenesisWaterRevisionV1Procedure.returnType), + __procedureSchema("admin_plan_worker_roster_v1", AdminPlanWorkerRosterV1Procedure.params, AdminPlanWorkerRosterV1Procedure.returnType), __procedureSchema("auth_resolver_get_fid_admission_v2", AuthResolverGetFidAdmissionV2Procedure.params, AuthResolverGetFidAdmissionV2Procedure.returnType), __procedureSchema("get_alpha_backend_info", GetAlphaBackendInfoProcedure.params, GetAlphaBackendInfoProcedure.returnType), __procedureSchema("get_my_admission_status", GetMyAdmissionStatusProcedure.params, GetMyAdmissionStatusProcedure.returnType), @@ -563,8 +638,10 @@ const proceduresSchema = __procedures( __procedureSchema("get_my_food_expedition_state_v1", GetMyFoodExpeditionStateV1Procedure.params, GetMyFoodExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_gold_expedition_state_v1", GetMyGoldExpeditionStateV1Procedure.params, GetMyGoldExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_resource_state_v1", GetMyResourceStateV1Procedure.params, GetMyResourceStateV1Procedure.returnType), + __procedureSchema("get_my_resource_state_v2", GetMyResourceStateV2Procedure.params, GetMyResourceStateV2Procedure.returnType), __procedureSchema("get_my_stone_expedition_state_v1", GetMyStoneExpeditionStateV1Procedure.params, GetMyStoneExpeditionStateV1Procedure.returnType), __procedureSchema("get_my_wood_expedition_state_v1", GetMyWoodExpeditionStateV1Procedure.params, GetMyWoodExpeditionStateV1Procedure.returnType), + __procedureSchema("get_my_worker_roster_v1", GetMyWorkerRosterV1Procedure.params, GetMyWorkerRosterV1Procedure.returnType), __procedureSchema("qa_observer_get_realm_attestation_v2", QaObserverGetRealmAttestationV2Procedure.params, QaObserverGetRealmAttestationV2Procedure.returnType), __procedureSchema("qa_observer_get_realm_snapshot_v1", QaObserverGetRealmSnapshotV1Procedure.params, QaObserverGetRealmSnapshotV1Procedure.returnType), ); diff --git a/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts b/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts new file mode 100644 index 00000000..cdd73634 --- /dev/null +++ b/src/spacetime/module_bindings/realm_worker_system_v_1_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + realmId: __t.string().primaryKey().name("realm_id"), + policyVersion: __t.string().name("policy_version"), + workersPerCastle: __t.u32().name("workers_per_castle"), + expectedCastleCount: __t.u32().name("expected_castle_count"), + expectedWorkerCount: __t.u32().name("expected_worker_count"), + rosterDigest: __t.string().name("roster_digest"), + mode: __t.string(), + legacyDrainRequired: __t.bool().name("legacy_drain_required"), + createdAt: __t.timestamp().name("created_at"), + activatedAt: __t.option(__t.timestamp()).name("activated_at"), +}); diff --git a/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts b/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts new file mode 100644 index 00000000..35750985 --- /dev/null +++ b/src/spacetime/module_bindings/recall_all_workers_v_1_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts b/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts new file mode 100644 index 00000000..f0d65bbd --- /dev/null +++ b/src/spacetime/module_bindings/recall_worker_v_1_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workerId: __t.string(), + idempotencyKey: __t.string(), +}; diff --git a/src/spacetime/module_bindings/types.ts b/src/spacetime/module_bindings/types.ts index 90aa16df..93d252e6 100644 --- a/src/spacetime/module_bindings/types.ts +++ b/src/spacetime/module_bindings/types.ts @@ -231,6 +231,58 @@ export const AdminWaterRevisionStatusV1 = __t.object("AdminWaterRevisionStatusV1 }); export type AdminWaterRevisionStatusV1 = __Infer; +export const AdminWorkerRosterPlanV1 = __t.object("AdminWorkerRosterPlanV1", { + ready: __t.bool(), + activationBlockedByLegacyRows: __t.bool(), + mode: __t.string(), + expectedCastleCount: __t.u64(), + expectedWorkerCount: __t.u64(), + actualWorkerCount: __t.u64(), + castlesMissingWorkers: __t.u64(), + castlesWithExtraWorkers: __t.u64(), + orphanWorkers: __t.u64(), + orphanAssignments: __t.u64(), + orphanOccupations: __t.u64(), + assignmentPublicMismatches: __t.u64(), + occupationSiteMismatches: __t.u64(), + legacyExpeditions: __t.u64(), + legacyOccupations: __t.u64(), + legacySchedules: __t.u64(), + rosterDigest: __t.string(), + rosterDigestExpected: __t.string(), +}); +export type AdminWorkerRosterPlanV1 = __Infer; + +export const AdminWorkerSystemStatusV1 = __t.object("AdminWorkerSystemStatusV1", { + systemRows: __t.u64(), + mode: __t.string(), + expectedCastleCount: __t.u64(), + expectedWorkerCount: __t.u64(), + actualWorkerCount: __t.u64(), + castlesMissingWorkers: __t.u64(), + castlesWithExtraWorkers: __t.u64(), + duplicateOrdinals: __t.u64(), + malformedWorkerIds: __t.u64(), + idleWorkers: __t.u64(), + outboundWorkers: __t.u64(), + gatheringWorkers: __t.u64(), + returningWorkers: __t.u64(), + assignments: __t.u64(), + occupations: __t.u64(), + schedules: __t.u64(), + orphanWorkers: __t.u64(), + orphanAssignments: __t.u64(), + orphanOccupations: __t.u64(), + assignmentPublicMismatches: __t.u64(), + occupationSiteMismatches: __t.u64(), + legacyExpeditions: __t.u64(), + legacyOccupations: __t.u64(), + legacySchedules: __t.u64(), + rosterDigest: __t.string(), + rosterDigestExpected: __t.string(), +}); +export type AdminWorkerSystemStatusV1 = __Infer; + export const AllowedFid = __t.object("AllowedFid", { fid: __t.u64(), enabled: __t.bool(), @@ -293,6 +345,28 @@ export const CastleSlotV1 = __t.object("CastleSlotV1", { }); export type CastleSlotV1 = __Infer; +export const CastleWorkerV1 = __t.object("CastleWorkerV1", { + workerId: __t.string(), + originCastleId: __t.u64(), + ordinal: __t.u32(), + status: __t.string(), + assignmentId: __t.option(__t.string()), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + startedAtMicros: __t.option(__t.u64()), + arrivesAtMicros: __t.option(__t.u64()), + gatheringEndsAtMicros: __t.option(__t.u64()), + returnStartedAtMicros: __t.option(__t.u64()), + returnsAtMicros: __t.option(__t.u64()), + routeSteps: __t.option(__t.u32()), + returnStartProgressBasisPoints: __t.option(__t.u32()), + timelineRevision: __t.u32(), + revision: __t.u64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type CastleWorkerV1 = __Infer; + export const FidWalletAttributionV1 = __t.object("FidWalletAttributionV1", { snapshotAttributionKey: __t.string(), attributionKey: __t.string(), @@ -491,6 +565,25 @@ export const MyResourceStateV1 = __t.object("MyResourceStateV1", { }); export type MyResourceStateV1 = __Infer; +export const MyResourceStateV2 = __t.object("MyResourceStateV2", { + fid: __t.u64(), + food: __t.u64(), + wood: __t.u64(), + stone: __t.u64(), + gold: __t.u64(), + workerPendingFood: __t.u64(), + workerPendingWood: __t.u64(), + workerPendingStone: __t.u64(), + workerPendingGold: __t.u64(), + observedAtMicros: __t.u64(), + settledThroughMicros: __t.u64(), + revision: __t.u64(), + resourcePolicyVersion: __t.string(), + workerPolicyVersion: __t.string(), + workerSystemMode: __t.string(), +}); +export type MyResourceStateV2 = __Infer; + export const MyStoneExpeditionStateV1 = __t.object("MyStoneExpeditionStateV1", { active: __t.bool(), expeditionId: __t.option(__t.string()), @@ -529,6 +622,16 @@ export const MyWoodExpeditionStateV1 = __t.object("MyWoodExpeditionStateV1", { }); export type MyWoodExpeditionStateV1 = __Infer; +export const MyWorkerRosterV1 = __t.object("MyWorkerRosterV1", { + fid: __t.u64(), + castleId: __t.u64(), + observedAtMicros: __t.u64(), + get workers() { + return __t.array(WorkerPrivateV1); + }, +}); +export type MyWorkerRosterV1 = __Infer; + export const Player = __t.object("Player", { fid: __t.u64(), identity: __t.identity(), @@ -798,6 +901,20 @@ export const RealmWaterRevisionV1 = __t.object("RealmWaterRevisionV1", { }); export type RealmWaterRevisionV1 = __Infer; +export const RealmWorkerSystemV1 = __t.object("RealmWorkerSystemV1", { + realmId: __t.string(), + policyVersion: __t.string(), + workersPerCastle: __t.u32(), + expectedCastleCount: __t.u32(), + expectedWorkerCount: __t.u32(), + rosterDigest: __t.string(), + mode: __t.string(), + legacyDrainRequired: __t.bool(), + createdAt: __t.timestamp(), + activatedAt: __t.option(__t.timestamp()), +}); +export type RealmWorkerSystemV1 = __Infer; + export const ResourceAccountV1 = __t.object("ResourceAccountV1", { fid: __t.u64(), castleId: __t.u64(), @@ -1009,6 +1126,84 @@ export const WoodSiteV1 = __t.object("WoodSiteV1", { }); export type WoodSiteV1 = __Infer; +export const WorkerAssignmentScheduleV1 = __t.object("WorkerAssignmentScheduleV1", { + scheduleId: __t.u64(), + scheduledAt: __t.scheduleAt(), + assignmentId: __t.string(), + workerId: __t.string(), + timelineRevision: __t.u32(), + stage: __t.string(), +}); +export type WorkerAssignmentScheduleV1 = __Infer; + +export const WorkerAssignmentV1 = __t.object("WorkerAssignmentV1", { + assignmentId: __t.string(), + workerId: __t.string(), + fid: __t.u64(), + originCastleId: __t.u64(), + resourceKind: __t.string(), + siteId: __t.string(), + phase: __t.string(), + startedAtMicros: __t.u64(), + arrivesAtMicros: __t.u64(), + gatheringEndsAtMicros: __t.u64(), + returnStartedAtMicros: __t.option(__t.u64()), + returnsAtMicros: __t.u64(), + routeSteps: __t.u32(), + returnStartProgressBasisPoints: __t.u32(), + settledThroughMicros: __t.u64(), + accruedAmount: __t.u64(), + materializedAmount: __t.u64(), + timelineRevision: __t.u32(), + policyVersion: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type WorkerAssignmentV1 = __Infer; + +export const WorkerCommandIdempotencyV1 = __t.object("WorkerCommandIdempotencyV1", { + requestKey: __t.string(), + fid: __t.u64(), + workerId: __t.option(__t.string()), + commandKind: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + assignmentId: __t.option(__t.string()), + resultRevision: __t.u64(), + createdAt: __t.timestamp(), +}); +export type WorkerCommandIdempotencyV1 = __Infer; + +export const WorkerNodeOccupationV1 = __t.object("WorkerNodeOccupationV1", { + nodeKey: __t.string(), + resourceKind: __t.string(), + siteId: __t.string(), + workerId: __t.string(), + workerOrdinal: __t.u32(), + originCastleId: __t.u64(), + assignmentId: __t.string(), + phase: __t.string(), + startedAtMicros: __t.u64(), + arrivesAtMicros: __t.u64(), + gatheringEndsAtMicros: __t.u64(), + timelineRevision: __t.u32(), +}); +export type WorkerNodeOccupationV1 = __Infer; + +export const WorkerPrivateV1 = __t.object("WorkerPrivateV1", { + workerId: __t.string(), + ordinal: __t.u32(), + status: __t.string(), + resourceKind: __t.option(__t.string()), + siteId: __t.option(__t.string()), + accruedAmount: __t.u64(), + materializedAmount: __t.u64(), + availableAmount: __t.u64(), + observedAtMicros: __t.u64(), + revision: __t.u64(), +}); +export type WorkerPrivateV1 = __Infer; + export const WorldTile = __t.object("WorldTile", { key: __t.string(), q: __t.i32(), diff --git a/src/spacetime/module_bindings/types/procedures.ts b/src/spacetime/module_bindings/types/procedures.ts index dcfd21fe..e08675cb 100644 --- a/src/spacetime/module_bindings/types/procedures.ts +++ b/src/spacetime/module_bindings/types/procedures.ts @@ -14,8 +14,10 @@ import * as AdminGetAlphaStatusV8Procedure from "../admin_get_alpha_status_v_8_p import * as AdminGetAlphaStatusV10Procedure from "../admin_get_alpha_status_v_10_procedure"; import * as AdminGetFidAuthEpochProcedure from "../admin_get_fid_auth_epoch_procedure"; import * as AdminGetSnapScanBatchAggregateV1Procedure from "../admin_get_snap_scan_batch_aggregate_v_1_procedure"; +import * as AdminGetWorkerSystemStatusV1Procedure from "../admin_get_worker_system_status_v_1_procedure"; import * as AdminInspectGenesisWaterLayoutV1Procedure from "../admin_inspect_genesis_water_layout_v_1_procedure"; import * as AdminInspectGenesisWaterRevisionV1Procedure from "../admin_inspect_genesis_water_revision_v_1_procedure"; +import * as AdminPlanWorkerRosterV1Procedure from "../admin_plan_worker_roster_v_1_procedure"; import * as AuthResolverGetFidAdmissionV2Procedure from "../auth_resolver_get_fid_admission_v_2_procedure"; import * as GetAlphaBackendInfoProcedure from "../get_alpha_backend_info_procedure"; import * as GetMyAdmissionStatusProcedure from "../get_my_admission_status_procedure"; @@ -23,8 +25,10 @@ import * as GetMyAdmissionStatusV2Procedure from "../get_my_admission_status_v_2 import * as GetMyFoodExpeditionStateV1Procedure from "../get_my_food_expedition_state_v_1_procedure"; import * as GetMyGoldExpeditionStateV1Procedure from "../get_my_gold_expedition_state_v_1_procedure"; import * as GetMyResourceStateV1Procedure from "../get_my_resource_state_v_1_procedure"; +import * as GetMyResourceStateV2Procedure from "../get_my_resource_state_v_2_procedure"; import * as GetMyStoneExpeditionStateV1Procedure from "../get_my_stone_expedition_state_v_1_procedure"; import * as GetMyWoodExpeditionStateV1Procedure from "../get_my_wood_expedition_state_v_1_procedure"; +import * as GetMyWorkerRosterV1Procedure from "../get_my_worker_roster_v_1_procedure"; import * as QaObserverGetRealmAttestationV2Procedure from "../qa_observer_get_realm_attestation_v_2_procedure"; import * as QaObserverGetRealmSnapshotV1Procedure from "../qa_observer_get_realm_snapshot_v_1_procedure"; @@ -44,10 +48,14 @@ export type AdminGetFidAuthEpochArgs = __Infer; export type AdminGetSnapScanBatchAggregateV1Args = __Infer; export type AdminGetSnapScanBatchAggregateV1Result = __Infer; +export type AdminGetWorkerSystemStatusV1Args = __Infer; +export type AdminGetWorkerSystemStatusV1Result = __Infer; export type AdminInspectGenesisWaterLayoutV1Args = __Infer; export type AdminInspectGenesisWaterLayoutV1Result = __Infer; export type AdminInspectGenesisWaterRevisionV1Args = __Infer; export type AdminInspectGenesisWaterRevisionV1Result = __Infer; +export type AdminPlanWorkerRosterV1Args = __Infer; +export type AdminPlanWorkerRosterV1Result = __Infer; export type AuthResolverGetFidAdmissionV2Args = __Infer; export type AuthResolverGetFidAdmissionV2Result = __Infer; export type GetAlphaBackendInfoArgs = __Infer; @@ -62,10 +70,14 @@ export type GetMyGoldExpeditionStateV1Args = __Infer; export type GetMyResourceStateV1Args = __Infer; export type GetMyResourceStateV1Result = __Infer; +export type GetMyResourceStateV2Args = __Infer; +export type GetMyResourceStateV2Result = __Infer; export type GetMyStoneExpeditionStateV1Args = __Infer; export type GetMyStoneExpeditionStateV1Result = __Infer; export type GetMyWoodExpeditionStateV1Args = __Infer; export type GetMyWoodExpeditionStateV1Result = __Infer; +export type GetMyWorkerRosterV1Args = __Infer; +export type GetMyWorkerRosterV1Result = __Infer; export type QaObserverGetRealmAttestationV2Args = __Infer; export type QaObserverGetRealmAttestationV2Result = __Infer; export type QaObserverGetRealmSnapshotV1Args = __Infer; diff --git a/src/spacetime/module_bindings/types/reducers.ts b/src/spacetime/module_bindings/types/reducers.ts index 5f35bdf7..fec9620b 100644 --- a/src/spacetime/module_bindings/types/reducers.ts +++ b/src/spacetime/module_bindings/types/reducers.ts @@ -40,6 +40,9 @@ import DispatchFoodExpeditionV1Reducer from "../dispatch_food_expedition_v_1_red import DispatchGoldExpeditionV1Reducer from "../dispatch_gold_expedition_v_1_reducer"; import DispatchStoneExpeditionV1Reducer from "../dispatch_stone_expedition_v_1_reducer"; import DispatchWoodExpeditionV1Reducer from "../dispatch_wood_expedition_v_1_reducer"; +import DispatchWorkerV1Reducer from "../dispatch_worker_v_1_reducer"; +import RecallAllWorkersV1Reducer from "../recall_all_workers_v_1_reducer"; +import RecallWorkerV1Reducer from "../recall_worker_v_1_reducer"; export type AcceptAlphaTermsV1Params = __Infer; export type AdminActivateGenesisWaterLayoutV1Params = __Infer; @@ -75,4 +78,7 @@ export type DispatchFoodExpeditionV1Params = __Infer; export type DispatchStoneExpeditionV1Params = __Infer; export type DispatchWoodExpeditionV1Params = __Infer; +export type DispatchWorkerV1Params = __Infer; +export type RecallAllWorkersV1Params = __Infer; +export type RecallWorkerV1Params = __Infer; diff --git a/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts b/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts new file mode 100644 index 00000000..e06a3e34 --- /dev/null +++ b/src/spacetime/module_bindings/worker_assignment_schedule_v_1_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + scheduleId: __t.u64().primaryKey().name("schedule_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + assignmentId: __t.string().name("assignment_id"), + workerId: __t.string().name("worker_id"), + timelineRevision: __t.u32().name("timeline_revision"), + stage: __t.string(), +}); diff --git a/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts new file mode 100644 index 00000000..f2c6c549 --- /dev/null +++ b/src/spacetime/module_bindings/worker_node_occupation_v_1_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + nodeKey: __t.string().primaryKey().name("node_key"), + resourceKind: __t.string().name("resource_kind"), + siteId: __t.string().name("site_id"), + workerId: __t.string().name("worker_id"), + workerOrdinal: __t.u32().name("worker_ordinal"), + originCastleId: __t.u64().name("origin_castle_id"), + assignmentId: __t.string().name("assignment_id"), + phase: __t.string(), + startedAtMicros: __t.u64().name("started_at_micros"), + arrivesAtMicros: __t.u64().name("arrives_at_micros"), + gatheringEndsAtMicros: __t.u64().name("gathering_ends_at_micros"), + timelineRevision: __t.u32().name("timeline_revision"), +});