finish first implementation of incremental updates

This commit is contained in:
Adam 2022-02-20 22:18:14 +01:00
parent dd45bece53
commit d14d0cd991
12 changed files with 614 additions and 97 deletions

View file

@ -1,62 +1,78 @@
import {ExcludeNullKeys} from "../global";
import {ExcludeNullKeys, NonNullableKeys} from "../types";
export namespace EventDataCache {
declare const global: {
cache: LuaTable<CacheKey<FactorioEventUpdate>, CacheEntry<FactorioEventUpdate>>,
DEFAULT_EXPIRATION_DURATION_TICKS: uint,
cache: LuaTable<CacheKey<FactorioEventUpdateType>, CacheEntry<FactorioEventUpdateType>>,
DEFAULT_CACHE_DURATION_TICKS: LuaTable<FactorioEventUpdateType, uint>,
DEFAULT_CACHE_DURATION_TICKS2: Record<FactorioEventUpdateType | "default", uint>,
MAP_CHUNK_CACHE_DURATION_TICKS: uint,
}
export function init() {
if (typeof global.cache == undefined) {
global.cache = new LuaTable<CacheKey<FactorioEventUpdate>, CacheEntry<FactorioEventUpdate>>()
global.cache = new LuaTable<CacheKey<FactorioEventUpdateType>, CacheEntry<FactorioEventUpdateType>>()
global.DEFAULT_EXPIRATION_DURATION_TICKS = 60 * 60 // ~60 seconds
global.DEFAULT_CACHE_DURATION_TICKS = new LuaTable<FactorioEventUpdateType, uint>()
global.DEFAULT_CACHE_DURATION_TICKS.set("PLAYER", 30) // 0.5 seconds
global.DEFAULT_CACHE_DURATION_TICKS.set("MAP_CHUNK", 60 * 15) // ~ 15 seconds
global.DEFAULT_CACHE_DURATION_TICKS.set("ENTITY", 60 * 2) // ~2 seconds
global.DEFAULT_CACHE_DURATION_TICKS2 = {
"PLAYER": 30, // 0.5 seconds
"MAP_CHUNK": 60 * 15, // ~ 15 seconds
"ENTITY": 60 * 2, // ~2 seconds
"default": 60
}
}
}
/**
* Update the cache data without restarting the 'time-to-emit' countdown, so that the data won't
* be emitted more than once per {@link CacheEntry.expirationDurationTicks}.
*/
export function throttle<T extends FactorioEventUpdate>(
key: CacheKey<T>,
mutate: (data: CacheData<T> | undefined) => void,
export function throttle<TYPE extends FactorioEventUpdateType>(
key: CacheKey<TYPE>,
mutate: CacheDataMutator<TYPE>,
) {
update(key, mutate, false)
}
/**
* Update the cache data and reset the 'time-to-emit' countdown, so the data won't be emitted
* until there's an inactivity gap of {@link CacheEntry.expirationDurationTicks}.
*/
export function debounce<T extends FactorioEventUpdate>(
key: CacheKey<T>,
mutate: (data: CacheData<T> | undefined) => void,
export function debounce<TYPE extends FactorioEventUpdateType>(
key: CacheKey<TYPE>,
mutate: CacheDataMutator<TYPE>,
) {
update(key, mutate, true)
}
function update<T extends FactorioEventUpdate>(
key: CacheKey<T>,
mutate: (data: CacheData<T> | undefined) => void,
function update<TYPE extends FactorioEventUpdateType>(
key: CacheKey<TYPE>,
mutate: CacheDataMutator<TYPE>,
resetLastUpdated: boolean,
) {
const entry = getCacheEntry(key)
mutate(entry?.data)
if (entry != undefined && resetLastUpdated) {
const entry = getCacheEntry(key) ?? createCacheEntry(key, mutate)
mutate(entry.data)
if (resetLastUpdated) {
entry.lastUpdatedTick = game.tick
}
}
/**
* Usage example: If an urgent update comes in, then the expiration can be reduced so that
* it's transmitted sooner.
*/
export function setExpiration(
key: CacheKey<any>,
key: CacheKey<FactorioEventUpdateType>,
expirationDurationTicks?: uint,
) {
const entry = getCacheEntry(key)
@ -65,9 +81,22 @@ export namespace EventDataCache {
}
}
function getCacheEntry<T extends FactorioEventUpdate>(
key: CacheKey<T>
): CacheEntry<T> | undefined {
export function extractExpired(): Array<CacheData<FactorioEventUpdateType>> {
const expiredData: Array<CacheData<FactorioEventUpdateType>> = []
for (let [key, entry] of global.cache) {
if (entry.isExpired()) {
expiredData.push(entry.data)
global.cache.delete(key)
}
}
return expiredData
}
function getCacheEntry<TYPE extends FactorioEventUpdateType>(
key: CacheKey<TYPE>
): CacheEntry<TYPE> | undefined {
if (!global.cache.has(key)) {
return undefined
}
@ -80,27 +109,57 @@ export namespace EventDataCache {
}
}
type UpdateType<T extends FactorioEventUpdate> = T["updateType"]
/** Only include the non-null properties */
export type CacheKey<T extends FactorioEventUpdate> = ExcludeNullKeys<T> & {
updateType: UpdateType<T>
}
/** Make the generated schema objects generic closed-polymorphic */
export type CacheData<T extends FactorioEventUpdate> = Omit<T, "type"> & {
updateType: UpdateType<T>
function createCacheEntry<TYPE extends FactorioEventUpdateType>(
key: CacheKey<TYPE>,
mutate: CacheDataMutator<TYPE>,
): CacheEntry<TYPE> {
// @ts-ignore // TODO remove ts-ignore for "not assignable to type"
const data: CacheData<TYPE> = {updateType: key.updateType}
mutate(data)
let entry: CacheEntry<TYPE> = new CacheEntry<TYPE>(data)
global.cache.set(key, entry)
return entry
}
class CacheEntry<T extends FactorioEventUpdate> {
/** Map a {@link FactorioEventUpdateType} to a {@link FactorioEventUpdate} DTO */
type ConvertUpdateType<TYPE extends FactorioEventUpdateType> =
TYPE extends "PLAYER" ? PlayerUpdate :
TYPE extends "MAP_CHUNK" ? MapChunkUpdate :
TYPE extends "ENTITY" ? EntityUpdate :
never
/** Only include the non-null properties, and make `updateType` specific */
export type CacheKey<TYPE extends FactorioEventUpdateType> =
CacheTyped<TYPE>
& Omit<ExcludeNullKeys<ConvertUpdateType<TYPE>>, "updateType">
/** Exclude {@link CacheKey} fields, and make `updateType` specific */
export type CacheData<TYPE extends FactorioEventUpdateType> =
CacheTyped<TYPE>
& NonNullableKeys<Partial<Omit<ConvertUpdateType<TYPE>, keyof CacheKey<TYPE> | "updateType">>>
export type CacheTyped<TYPE extends FactorioEventUpdateType> = {
readonly updateType: TYPE
}
export type CacheDataMutator<TYPE extends FactorioEventUpdateType> =
(data: CacheData<TYPE>) => void
class CacheEntry<TYPE extends FactorioEventUpdateType> {
lastUpdatedTick: uint = game.tick
expirationDurationTicks?: uint
data: CacheData<T>
data: CacheData<TYPE>
constructor(
data: CacheData<T>,
data: CacheData<TYPE>,
expirationDurationTicks?: uint,
) {
this.data = data
@ -110,16 +169,18 @@ export namespace EventDataCache {
}
isExpired(): boolean {
return game.tick -
this.lastUpdatedTick >
(this.expirationDurationTicks ?? global.DEFAULT_EXPIRATION_DURATION_TICKS)
const cacheDuration =
this.expirationDurationTicks ?? global.DEFAULT_CACHE_DURATION_TICKS2[this.data.updateType]
return game.tick - this.lastUpdatedTick > cacheDuration
}
}
function isType<T extends FactorioEventUpdate>(
data: CacheEntry<T>,
updateType: UpdateType<T>,
): data is CacheEntry<T> {
function isType<TYPE extends FactorioEventUpdateType>(
data: CacheEntry<TYPE>,
updateType: TYPE,
): data is CacheEntry<TYPE> {
return data.data.updateType == updateType
}
@ -129,7 +190,7 @@ export namespace EventDataCache {
// type: string
// }
//
// interface Update<T extends FactorioEventUpdateType> {
// interface Update<TYPE extends FactorioEventUpdateTypeType> {
// type: T
// }
//

View file

@ -0,0 +1,52 @@
import {EventDataCache} from "./EventDataCache";
import {emitPacket} from "../emitKafkatorioPacket";
// import { global } from "../global";
/** Every 0.1 seconds */
script.on_nth_tick(6, (nthTick: NthTickEventData) => {
const cachedEvents: Array<EventDataCache.CacheData<any>> = EventDataCache.extractExpired()
for (const event of cachedEvents) {
const packet: FactorioEventUpdatePacket = {
tick: nthTick.tick,
modVersion: global.MOD_VERSION,
packetType: "UPDATE",
update: event,
}
emitPacket(packet)
}
})
//
// function createEntityUpdate(cacheKey: EntityCacheKey, events: EventName[]): EntityUpdate | null {
//
//
// return null
// }
//
// interface EntityUpdate {
// name: string
//
// unit_number: uint
//
// unhandledEvents?: string[]
//
// chunkPosition: MapChunkPosition
// graphics_variation?: uint8
// health?: float
// isActive?: boolean
// isRotatable?: boolean
// last_user?: uint
// localised_description?: string
// localised_name?: string
// prototype?: string
//
// // ghost_localised_description?: string
// // ghost_localised_name?: string
// // ghost_name?: string
// // ghost_prototype?: string
// // ghost_unit_number?: uint
// }

View file

@ -1,5 +1,6 @@
import {Converters} from "../events/converters";
import {emitPacket} from "../emitKafkatorioPacket";
// import { global } from "../global";
export function emitPrototypes() {
emitPacket<FactorioPrototypes>({

View file

@ -1,7 +1,6 @@
/** Emit a serialised KafkatorioPacket */
export function emitPacket<T extends KafkatorioPacket>(packet: T) {
let data = game.table_to_json(packet)
// localised_print(`KafkatorioPacket: ${packet.packetType}`)
localised_print(`KafkatorioPacket: ${data}`)
// rcon.print(`KafkatorioPacket: ${data}`)
}

View file

@ -1,19 +1,19 @@
export namespace Converters {
export function playerToTable(player: LuaPlayer): PlayerData {
let charIds = entitiesToUnitNumbers(player.get_associated_characters())
return {
objectName: player.object_name,
name: player.name,
characterUnitNumber: player.character?.unit_number ?? null,
associatedCharactersUnitNumbers: charIds,
position: mapEntityPosition(player.position),
colour: mapColour(player.color),
chatColour: mapColour(player.chat_color),
lastOnline: player.last_online
}
}
// export function playerToTable(player: LuaPlayer): PlayerData {
// let charIds = entitiesToUnitNumbers(player.get_associated_characters())
// return {
// objectName: player.object_name,
//
// name: player.name,
// characterUnitNumber: player.character?.unit_number ?? null,
// associatedCharactersUnitNumbers: charIds,
// position: mapEntityPosition(player.position),
// colour: mapColour(player.color),
// chatColour: mapColour(player.chat_color),
// lastOnline: player.last_online
// }
// }
export function entityToTable(entity: LuaEntity): EntityData {
@ -28,9 +28,10 @@ export namespace Converters {
active: entity.active,
health: entity.health ?? null,
healthRatio: entity.get_health_ratio(),
surfaceIndex: entity.surface.index,
surface: entity.surface.index,
unitNumber: entity.unit_number ?? null,
position: mapEntityPosition(entity.position),
force: entity.force.index,
// player data
playerIndex: player?.index ?? null
@ -103,6 +104,10 @@ export namespace Converters {
}
}
export function convertTiles(tiles: LuaTile[]): MapTile[] {
return tiles.map((tile: LuaTile) => convertTile(tile))
}
export function convertBoundingBox(bb: BoundingBoxRead): MapBoundingBox {
return {
topLeft: mapTilePosition(bb.left_top),

View file

@ -0,0 +1,8 @@
function isEventType<ED extends EventData | CustomInputEvent>(
eventData: EventData,
type: ED extends CustomInputEvent ? string : EventId<ED>
): eventData is ED {
let id: EventId<EventData> | string = eventData.name
return id == type
}

View file

@ -1,14 +1,14 @@
import {Converters} from "./converters"
import {emitEvent} from "./emitEvent";
export function handlePlayerUpdate(tick: uint, eventType: string, playerIndex: uint) {
let player: LuaPlayer = game.players[playerIndex]
let table = Converters.playerToTable(player)
emitEvent(table, tick, eventType)
handleCharactersEvent(tick, eventType, playerIndex)
}
// export function handlePlayerUpdate(tick: uint, eventType: string, playerIndex: uint) {
// let player: LuaPlayer = game.players[playerIndex]
// let table = Converters.playerToTable(player)
//
// emitEvent(table, tick, eventType)
//
// handleCharactersEvent(tick, eventType, playerIndex)
// }
export function handleCharactersEvent(tick: uint, eventType: string, playerIndex: uint) {

View file

@ -0,0 +1,213 @@
import {EventDataCache} from "../cache/EventDataCache";
import CacheKey = EventDataCache.CacheKey;
import CacheData = EventDataCache.CacheData;
type MapChunkUpdater = (data: CacheData<"MAP_CHUNK">) => void
function mapTilesUpdateDebounce(
surface: LuaSurface | undefined,
chunkPosition: ChunkPositionTable,
tiles: TileRead[],
updater?: MapChunkUpdater
) {
if (surface == undefined) {
return
}
EventDataCache.debounce<"MAP_CHUNK">(
{
surfaceIndex: surface.index,
chunkPosition: chunkPosition,
updateType: "MAP_CHUNK",
},
data => {
if (data.tiles == undefined) {
data.tiles = new Map<MapTilePosition, string>()
}
for (const tile of tiles) {
const position: MapTilePosition = {x: tile.position.x, y: tile.position.y}
data.tiles.set(position, tile.name)
}
if (updater != undefined) {
updater(data)
}
}
)
}
function getTiles(
surface: LuaSurface | undefined,
area: BoundingBoxTable
): LuaTile[] {
return surface?.find_tiles_filtered({
area: area,
// collision_mask: ["ground-tile", "water-tile"]
}
) ?? []
}
function getSurface(surfaceIndex: uint): LuaSurface | undefined {
return game.surfaces[surfaceIndex]
}
script.on_event(
defines.events.on_chunk_generated,
(e: OnChunkGeneratedEvent) => {
let tiles = getTiles(e.surface, e.area)
mapTilesUpdateDebounce(e.surface, e.position, tiles)
}
)
script.on_event(
defines.events.on_chunk_charted,
(e: OnChunkChartedEvent) => {
const surface = getSurface(e.surface_index)
if (surface == undefined) {
return
}
let tiles = getTiles(surface, e.area)
mapTilesUpdateDebounce(surface, e.position, tiles, (data => {
data.force = e.force.index
}))
}
)
script.on_event(
defines.events.script_raised_set_tiles,
(e: ScriptRaisedSetTilesEvent) => {
const surface = getSurface(e.surface_index)
if (surface == undefined) {
return
}
const groupedTiles = groupTiles(e.tiles)
for (const [chunkPos, tiles] of groupedTiles) {
mapTilesUpdateDebounce(surface, chunkPos, tiles)
}
}
)
function groupTiles(tiles: TileRead[]): Map<MapChunkPosition, TileRead[]> {
const mapChunkPositionToTiles = new Map<MapChunkPosition, TileRead[]>()
for (const tile of tiles) {
const chunkPosition = {
x: math.floor((tile.position.x / 32)),
y: math.floor((tile.position.y / 32)),
}
if (!mapChunkPositionToTiles.has(chunkPosition)) {
mapChunkPositionToTiles.set(chunkPosition, [])
}
mapChunkPositionToTiles.get(chunkPosition)?.push(tile)
}
return mapChunkPositionToTiles
}
function convertOldPosition(
oldTiles: OldTileAndPosition[],
placedTile: LuaTilePrototype,
): TileRead[] {
return oldTiles.map((tile) => {
return {
position: {x: tile.position.x, y: tile.position.y},
name: placedTile.name,
}
}
)
}
function onBuildTileEvent(event: OnPlayerBuiltTileEvent | OnRobotBuiltTileEvent) {
const surface = getSurface(event.surface_index)
if (surface == undefined) {
return
}
const tiles: TileRead[] = convertOldPosition(event.tiles, event.tile)
const groupedTiles = groupTiles(tiles)
for (const [chunkPos, tiles] of groupedTiles) {
mapTilesUpdateDebounce(
surface,
chunkPos,
tiles,
(data => {
if (isEventType(event, defines.events.on_player_built_tile)) {
data.player = event.player_index
return
}
if (isEventType(event, defines.events.on_robot_built_tile)) {
data.robot = {
unitNumber: event.robot?.unit_number ?? null,
name: event.robot.name,
type: event.robot.type,
}
return
}
// this line will not compile if the previous 'if' statements are not exhaustive
// noinspection UnnecessaryLocalVariableJS,JSUnusedLocalSymbols
const exhaustiveCheck: never = event
}
)
)
}
}
script.on_event(defines.events.on_player_built_tile, (e: OnPlayerBuiltTileEvent) => onBuildTileEvent(e))
script.on_event(defines.events.on_robot_built_tile, (e: OnRobotBuiltTileEvent) => onBuildTileEvent(e))
// script.on_event(
// defines.events.on_player_mined_tile,
// (e: OnPlayerMinedTileEvent) => {
// // need to group tiles by ChunkPosition...
//
// }
// )
// script.on_event(
// defines.events.on_robot_mined_tile,
// (e: OnRobotMinedTileEvent) => {
// // need to group tiles by ChunkPosition...
//
// }
// )
script.on_event(
defines.events.on_pre_chunk_deleted,
(e: OnPreChunkDeletedEvent) => {
const surface = getSurface(e.surface_index)
if (surface == undefined) {
return
}
for (const position of e.positions) {
const key: CacheKey<"MAP_CHUNK"> = {
surfaceIndex: surface.index,
chunkPosition: position,
updateType: "MAP_CHUNK",
}
EventDataCache.debounce<"MAP_CHUNK">(
key,
data => {
data.isDeleted = true
}
)
// deletion is important - emit ASAP
EventDataCache.setExpiration(key, 0)
}
}
)

View file

@ -0,0 +1,178 @@
import {EventDataCache} from "../cache/EventDataCache";
import {Converters} from "./converters";
import CacheData = EventDataCache.CacheData;
type PlayerUpdater = (player: LuaPlayer, data: CacheData<"PLAYER">) => void
function playerUpdateThrottle(
playerIndex: uint,
mutate: PlayerUpdater,
) {
EventDataCache.throttle<"PLAYER">(
{index: playerIndex, updateType: "PLAYER"},
(data => {
const player = game.players[playerIndex]
if (player != undefined) {
mutate(player, data)
}
})
)
}
function playerUpdateDebounce(playerIndex: uint, mutate: PlayerUpdater) {
EventDataCache.debounce<"PLAYER">(
{index: playerIndex, updateType: "PLAYER"},
(data => {
const player = game.players[playerIndex]
mutate(player, data)
})
)
}
function playerOnlineInfo(player: LuaPlayer, data: CacheData<"PLAYER">) {
data.lastOnline = player.last_online
data.onlineTime = player.online_time
data.afkTime = player.afk_time
data.isConnected = player.connected
}
script.on_event(
defines.events.on_player_joined_game,
(e: OnPlayerJoinedGameEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.isAdmin = player.admin
data.characterUnitNumber = player.character?.unit_number
data.chatColour = Converters.mapColour(player.chat_color)
data.colour = Converters.mapColour(player.color)
data.forceIndex = player.force.index
data.name = player.name
data.isShowOnMap = player.show_on_map
data.isSpectator = player.spectator
data.tag = player.tag
playerOnlineInfo(player, data)
}
)
}
)
script.on_event(
defines.events.on_player_changed_position,
(e: OnPlayerChangedPositionEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.position = player.position
}
)
}
)
script.on_event(
defines.events.on_player_changed_surface,
(e: OnPlayerChangedSurfaceEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.position = player.position
data.surfaceIndex = player.surface.index
}
)
}
)
script.on_event(
defines.events.on_player_died,
(e: OnPlayerDiedEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.ticksToRespawn = player.ticks_to_respawn
playerOnlineInfo(player, data)
if (e.cause != undefined) {
data.diedCause = {
unitNumber: e.cause.unit_number ?? null,
name: e.cause.name,
type: e.cause.type
}
}
}
)
}
)
script.on_event(
defines.events.on_player_banned,
(e: OnPlayerBannedEvent) => {
if (e.player_index != undefined) {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.bannedReason = e.reason
playerOnlineInfo(player, data)
}
)
}
}
)
script.on_event(
defines.events.on_player_unbanned,
(e: OnPlayerUnbannedEvent) => {
if (e.player_index != undefined) {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.bannedReason = e.reason
playerOnlineInfo(player, data)
}
)
}
}
)
script.on_event(
defines.events.on_player_kicked,
(e: OnPlayerKickedEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.kickedReason = e.reason
playerOnlineInfo(player, data)
}
)
}
)
const disconnectReasons = new LuaTable<defines.disconnect_reason, keyof typeof defines.disconnect_reason>()
for (const [name, disconnectId] of pairs(defines.disconnect_reason)) {
disconnectReasons.set(disconnectId, name)
}
script.on_event(
defines.events.on_pre_player_left_game,
(e: OnPrePlayerLeftGameEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.disconnectReason = disconnectReasons.get(e.reason)
playerOnlineInfo(player, data)
}
)
}
)
script.on_event(
defines.events.on_player_removed,
(e: OnPlayerRemovedEvent) => {
playerUpdateThrottle(
e.player_index,
(player, data) => {
data.isRemoved = true
playerOnlineInfo(player, data)
}
)
}
)

View file

@ -2,12 +2,12 @@ import {
handleChunkUpdate,
handleConsoleChat,
handleEntityUpdate,
handlePlayerUpdate,
// handlePlayerUpdate,
handleSurfaceUpdate,
handleTilesUpdate
} from "./handlers";
import {Queue} from "../queue/queue";
import {EventName} from "../global";
import {EventName} from "../types";
const mapEventIdToName = new LuaTable<defines.Events, EventName>()
@ -32,24 +32,24 @@ script.on_event(
defines.events.on_player_mined_entity,
(e: OnPlayerMinedEntityEvent) => {
let eventName = mapEventIdToName.get(e.name)
handlePlayerUpdate(e.tick, eventName, e.player_index)
// handlePlayerUpdate(e.tick, eventName, e.player_index)
handleEntityUpdate(e.tick, eventName, e.entity)
}
)
script.on_event(
defines.events.on_player_joined_game,
(e: OnPlayerJoinedGameEvent) => {
handlePlayerUpdate(e.tick, mapEventIdToName.get(e.name), e.player_index)
}
)
// script.on_event(
// defines.events.on_player_joined_game,
// (e: OnPlayerJoinedGameEvent) => {
// handlePlayerUpdate(e.tick, mapEventIdToName.get(e.name), e.player_index)
// }
// )
script.on_event(
defines.events.on_player_changed_position,
(e: OnPlayerChangedPositionEvent) => {
handlePlayerUpdate(e.tick, mapEventIdToName.get(e.name), e.player_index)
}
)
// script.on_event(
// defines.events.on_player_changed_position,
// (e: OnPlayerChangedPositionEvent) => {
// handlePlayerUpdate(e.tick, mapEventIdToName.get(e.name), e.player_index)
// }
// )
script.on_event(
defines.events.on_tick,
@ -113,12 +113,3 @@ script.on_event(
)
}
)
function isEventType<ED extends EventData | CustomInputEvent>(
eventData: EventData,
type: ED extends CustomInputEvent ? string : EventId<ED>
): eventData is ED {
let id: EventId<EventData> | string = eventData.name
return id == type
}

View file

@ -2,11 +2,3 @@ declare const global: {
MOD_VERSION: string
FACTORIO_VERSION: string
}
export type EventName = keyof typeof defines.events
type NonNullKeys<T> = {
[P in keyof T]: null extends Exclude<T[P], undefined> ? never : P
}[keyof T];
type ExcludeNullKeys<T> = Pick<T, NonNullKeys<T>>

View file

@ -0,0 +1,17 @@
export type EventName = keyof typeof defines.events
/** Get all keys of `TYPE` that are not nullable. */
type NonNullKeys<TYPE> = {
[KEY in keyof TYPE]: null extends Exclude<TYPE[KEY], undefined> ? never : KEY
}[keyof TYPE];
/** Exclude all nullable keys from `TYPE`. */
type ExcludeNullKeys<TYPE> = Pick<TYPE, NonNullKeys<TYPE>>
/** Make all keys of `TYPE` non-nullable. */
type NonNullableKeys<TYPE> = {
[KEY in keyof TYPE]: NonNullable<TYPE[KEY]>;
}