diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00f9e64..2832f65 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
* Feat: Update Soldat protocol (#642)
* Feat: TOXIKK (2016) - Added support (#641)
* Feat: Renegade X (2014) - Added support (#643)
+* Feat: Hawakening (2024) - Added support (#648)
## 5.1.3
* Fix: `Deus Ex` using the wrong protocol (#621)
diff --git a/GAMES_LIST.md b/GAMES_LIST.md
index 70a9f07..68675bd 100644
--- a/GAMES_LIST.md
+++ b/GAMES_LIST.md
@@ -150,6 +150,7 @@
| gus | Gore: Ultimate Soldier | |
| halo | Halo | |
| halo2 | Halo 2 | |
+| hawakening | Hawakening | [Notes](#hawakening) |
| heretic2 | Heretic II | |
| hexen2 | Hexen II | |
| hiddendangerous2 | Hidden & Dangerous 2 | |
@@ -480,6 +481,22 @@ If you know your use case, it's better to use a single protocol or make your own
Farming Simulator servers need a token (reffered as code in the game). It can be obtained at your server's web interface (http://ip:port/settings.html). It can be passed to GameDig with the additional option: `token`. It does only work for your own server.
The response includes much information about the server. Currently, only the fields about server information (name, map, version, etc.), players and mods are parsed.
+
+### Hawakening
+Querying server info for Hawakening requires a _ServerId_ to be passed to GameDig instead of an IP address. You can acquire such a _ServerId_ from the master query protocol _hawakeningmaster_ [type: `protocol-hawakeningmaster`] (see `raw.servers[]raw.listing.Guid`).
+
+Additionally, the master server requires authorization. A **user profile is required** for querying the API. Such a profile can be created on the [_official page_](https://hawakening.com/enlist).
+
+- Provide a Server Id via `serverId`
+- Provide a client profile with `username` (email address, not callsign)
+
+And one of the following options for gaining access:
+- Provide a client access token via the option `token`
+- Provide the user profile password via the option `password`
+
+> **_NOTE:_** The protocol `hawakening` will query additional server info by requesting a matchmaking _token_, which will fail for full servers. Due to this, the IP address and port cannot be queried for such servers.
+
+
Protocols with Additional Notes
---
diff --git a/lib/games.js b/lib/games.js
index 3b52ca1..b9ae6b1 100644
--- a/lib/games.js
+++ b/lib/games.js
@@ -1457,6 +1457,18 @@ export const games = {
protocol: 'gamespy2'
}
},
+ hawakening: {
+ name: 'Hawakening',
+ release_year: 2024,
+ options: {
+ port: 7777,
+ port_query: 27015,
+ protocol: 'hawakening'
+ },
+ extra: {
+ doc_notes: 'hawakening'
+ }
+ },
heretic2: {
name: 'Heretic II',
release_year: 1998,
diff --git a/protocols/hawakening.js b/protocols/hawakening.js
new file mode 100644
index 0000000..836531e
--- /dev/null
+++ b/protocols/hawakening.js
@@ -0,0 +1,13 @@
+import hawakeningmaster from './hawakeningmaster.js'
+
+/**
+ * Implements the protocol for Hawakening, a fan project of the UnrealEngine3 based game HAWKEN
+ * using a Meteor backend for the master server
+ */
+export default class hawakening extends hawakeningmaster {
+ constructor () {
+ super()
+ this.doQuerySingle = true
+ this.requireToken = true
+ }
+}
diff --git a/protocols/hawakeningmaster.js b/protocols/hawakeningmaster.js
new file mode 100644
index 0000000..f8484d1
--- /dev/null
+++ b/protocols/hawakeningmaster.js
@@ -0,0 +1,718 @@
+import Core from './core.js'
+// import Ajv from 'ajv'
+// const ajv = new Ajv()
+
+/**
+ * Implements the protocol for retrieving a master list for Hawakening, a fan project of the UnrealEngine3 based game HAWKEN
+ * using a Meteor backend for the master server
+ */
+export default class hawakeningmaster extends Core {
+ constructor () {
+ super()
+
+ // backend API url for original Hawken release
+ // const meteorUri = 'https://v2-services-live-pc.playhawken.com'
+ // Hawakening API for public release in 2024
+ const meteorUri = 'https://hawakening.com/api'
+
+ this.backendApi = new MeteorBackendApi(this, meteorUri)
+ this.backendApi.setLogger(this.logger)
+
+ // set when querying needs access token
+ this.requireToken = false
+ // set when querying for specific server only
+ this.doQuerySingle = false
+ // set to logout on cleanup (to revoke access token)
+ this.doLogout = true
+
+ // stored user, queried from backend
+ this.userInfo = null
+
+ // Don't use the tcp ping probing
+ this.usedTcp = true
+ }
+
+ async run (state) {
+ await this.retrieveClientAccessToken()
+ await this.retrieveUser()
+
+ await this.queryInfo(state)
+ await this.cleanup(state)
+ }
+
+ async queryInfo (state) {
+ if (this.doQuerySingle) {
+ await this.queryInfoSingle(state)
+ } else {
+ await this.queryInfoMultiple(state)
+ }
+ }
+
+ async queryInfoMultiple (state) {
+ const servers = await this.getMasterServerList()
+
+ // pass processed servers as raw list
+ state.raw.servers = servers.map((serverListing) => {
+ // TODO: may use any other deep-copy method like structuredClone() (in Node.js 17+)
+ // or use a method of Core to retrieve a clean state
+ const serverState = JSON.parse(JSON.stringify(state))
+
+ // set state properties based on received server info
+ this.populateProperties(serverState, { serverListing })
+ return serverState
+ })
+ }
+
+ async queryInfoSingle (state) {
+ const servers = await this.getMasterServerList()
+ const serverListing = servers.find((server) => {
+ return server.Guid === this.options.serverId
+ })
+
+ this.logger.debug('Server Listing:', serverListing)
+ if (serverListing == null) {
+ throw new Error('Server not found in master server listing')
+ }
+
+ const serverInfo = await this.getServerInfo(serverListing)
+ this.logger.debug('Server Info:', serverInfo)
+ if (!serverInfo) {
+ throw new Error('Invalid server info received')
+ }
+
+ // set state properties based on received server info
+ this.populateProperties(state, { serverListing, serverInfo })
+ }
+
+ async cleanup (state) {
+ await this.sendExitMessage()
+ await this.sendLogout()
+
+ this.backendApi.cleanup()
+ this.userInfo = null
+ }
+
+ /**
+ * Translates raw properties into known properties
+ * @param {Object} state Parsed data
+ * @param {Object} data Queried data
+ */
+ populateProperties (state, data) {
+ const { serverListing: listing, serverInfo: info } = data
+
+ if (info) {
+ state.gameHost = info.AssignedServerIp || null
+ state.gamePort = info.AssignedServerPort || null
+ }
+
+ state.name = listing.ServerName || ''
+ state.map = listing.Map || ''
+ state.password = !!listing.DeveloperData?.PasswordHash
+
+ state.numplayers = listing.Users?.length || 0
+ state.maxplayers = listing.MaxUsers || 0
+ state.version = listing.GameVersion || ''
+
+ // provide raw server info
+ Object.assign(state.raw, { listing, info })
+ }
+
+ async retrieveClientAccessToken () {
+ if (this.options.token) {
+ this.doLogout = false
+ this.backendApi.accessToken = this.options.token
+ await this.checkAccess()
+ return
+ }
+
+ if (!this.options.username && !this.requireToken) {
+ this.logger.debug('retrieveClientAccessToken: No username provided but no token required for current protocol.')
+ return
+ }
+
+ this.logger.debug(`Retrieving user access token for ${this.options.username}...`)
+ const response = await this.backendApi.getClientAccessToken(this.options.username, this.options.password)
+
+ const tag = 'access token'
+ MeteorBackendApi.AssertResponse(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['Access Grant Not Issued: Unrecognized options for login request'], errorMessage: 'No user name or password' })
+ MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['Access Grant Not Issued: User not found'], errorMessage: 'Invalid user name' })
+ MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['Access Grant Not Issued: Incorrect password'], errorMessage: 'Incorrect password' })
+ MeteorBackendApi.AssertResponseStatus(response, tag, { printStatus: true })
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['User Logged In'] })
+ MeteorBackendApi.AssertResponseData(response, tag)
+
+ this.backendApi.accessToken = response.Result
+ await this.checkAccess()
+ }
+
+ async retrieveUser () {
+ if (!this.options.username && !this.requireToken) {
+ this.logger.debug('retrieveUser: No username provided but no token required for current protocol.')
+ return
+ }
+
+ this.userInfo = await this.getUserInfo()
+ }
+
+ async checkAccess () {
+ this.logger.debug('Checking access ...')
+ const responseServices = await this.backendApi.getStatusServices()
+ MeteorBackendApi.AssertResponseStatus(responseServices, 'service status')
+ MeteorBackendApi.AssertResponseMessage(responseServices, 'service status', { expected: ['Status found'] })
+
+ const responseTest = await this.backendApi.getBundles()
+ MeteorBackendApi.AssertResponseStatus(responseTest, 'bundles')
+ MeteorBackendApi.AssertResponseMessage(responseTest, 'bundles', { expected: ['Bundles Filter successful'] })
+ }
+
+ async getUserInfo () {
+ this.logger.debug(`Requesting user info for ${this.options.username} ...`)
+ const response = await this.backendApi.getUserInfo(this.options.username)
+
+ const tag = 'user info'
+ MeteorBackendApi.AssertResponse(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { match: ['User not found'], errorMessage: 'Invalid or no user name' })
+ MeteorBackendApi.AssertResponseStatus(response, tag, { printStatus: true })
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Userfound'] })
+ MeteorBackendApi.AssertResponseData(response, tag)
+ return response.Result
+ }
+
+ async getMasterServerList () {
+ this.logger.debug('Requesting game servers ...')
+ const response = await this.backendApi.getMasterServerList()
+
+ const tag = 'server list'
+ MeteorBackendApi.AssertResponseStatus(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Listings found'] })
+ MeteorBackendApi.AssertResponseData(response, tag)
+
+ const servers = response.Result
+ if (!Array.isArray(servers)) {
+ throw new Error('Invalid data received from master server. Expecting list of data')
+ }
+ if (servers.length === 0) {
+ throw new Error('No data received from master server.')
+ }
+
+ // TODO: Ajv response validation
+ // const isDataValid = ajv.validate(MasterServerResponseSchema, servers)
+ // if (!isDataValid) {
+ // throw new Error(`Received master server data is unknown/invalid: ${ajv.errorsText(ajv.errors)}`)
+ // }
+
+ return servers
+ }
+
+ async getServerInfo (serverListing) {
+ // match info is received by requesting a matchmaking "token"
+ // if the server is at capacity, the response won't provide valid data (500 error)
+ // return an empty server info when server is already full
+ if (serverListing.MaxUsers == serverListing.Users?.length) {
+ return {}
+ }
+
+ const serverToken = await this.getServerToken(serverListing)
+ const matchInfo = await this.getMatchInfo(serverToken)
+ return matchInfo
+ }
+
+ async getServerToken (serverListing) {
+ this.logger.debug(`Requesting server token ${serverListing.Guid} ...`)
+ const response = await this.backendApi.getServerToken(serverListing, this.userInfo)
+
+ const tag = 'server token'
+ MeteorBackendApi.AssertResponseStatus(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Succesfully created the advertisement'] })
+ MeteorBackendApi.AssertResponseData(response, tag)
+ return response.Result
+ }
+
+ async getMatchInfo (serverToken) {
+ this.logger.debug(`Requesting match info ${serverToken} ...`)
+ const response = await this.backendApi.getMatchInfo(serverToken)
+
+ const tag = 'match info'
+ MeteorBackendApi.AssertResponseStatus(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Successfully loaded ClientMatchmakingAdvertisement.'] })
+ MeteorBackendApi.AssertResponseData(response, tag)
+ return response.Result
+ }
+
+ async sendExitMessage () {
+ // in case of non-authorized query, early out and skip sending logout message
+ if (!this.backendApi.accessToken || !this.userInfo) {
+ return
+ }
+
+ this.logger.debug('Sending exit notify message ...')
+ const response = await this.backendApi.notifyExit(this.userInfo)
+
+ const tag = 'exit message'
+ MeteorBackendApi.AssertResponseStatus(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['Event emission successful'] })
+ }
+
+ async sendLogout () {
+ // in case of no logged user or non-authorized query, early out and skip sending logout message
+ if (!this.doLogout || !this.backendApi.accessToken || !this.userInfo) {
+ return
+ }
+
+ this.logger.debug(`Sending logout message for ${this.userInfo?.EmailAddress || this.userInfo.Guid}...`)
+ const response = await this.backendApi.logout(this.userInfo)
+
+ const tag = 'logout message'
+ MeteorBackendApi.AssertResponseStatus(response, tag)
+ MeteorBackendApi.AssertResponseMessage(response, tag, { expected: ['AccessGrant Revoked'] })
+ }
+}
+
+/**
+ * Deeply merges two objects, combining their properties recursively.
+ *
+ * If both objects have a property with the same key and that property is an object,
+ * the properties of the second object will be merged into the first object's property.
+ * If the property is not an object or if it does not exist in the first object,
+ * the property from the second object will overwrite the property in the first object.
+ *
+ * @param {Object} obj1 - The first object to merge.
+ * @param {Object} obj2 - The second object to merge.
+ * @returns {Object} A new object containing the merged properties of both input objects.
+ */
+function deepMerge (obj1, obj2) {
+ const result = { ...obj1 }
+
+ for (const key in obj2) {
+ if (Object.hasOwn(obj2, key)) {
+ if (obj2[key] instanceof Object && obj1[key] instanceof Object) {
+ result[key] = deepMerge(obj1[key], obj2[key])
+ } else {
+ result[key] = obj2[key]
+ }
+ }
+ }
+
+ return result
+}
+
+function isObject (item) {
+ return (typeof item === 'object' && !Array.isArray(item) && item !== null)
+}
+
+/**
+ * Class representing a client for the Meteor Backend API.
+ *
+ * This class provides methods for interacting with the Meteor Backend API, including
+ * authentication, retrieving user information, and handling server-related operations.
+ */
+export class MeteorBackendApi {
+ #accessToken = null
+ #protocol = null
+ #apiUri = null
+
+ /**
+ * Creates an instance of the MeteorBackendApi.
+ *
+ * @param {Object} protocol - The protocol object to handle requests.
+ * @param {string} apiUri - The base URI for the API.
+ */
+ constructor (protocol, apiUri) {
+ this.#protocol = protocol
+ this.#apiUri = apiUri
+ this.logger = null
+ }
+
+ /**
+ * The base URI of the API.
+ *
+ * @returns {string} The API URI.
+ */
+ get apiUri () {
+ return this.#apiUri
+ }
+
+ /**
+ * Sets the current access token
+ * @param {string} value the access token
+ */
+ set accessToken (value) {
+ this.#accessToken = value
+ }
+
+ /**
+ * Returns the current access token
+ */
+ get accessToken () {
+ return this.#accessToken
+ }
+
+ /**
+ * Sets the logger for the instance.
+ *
+ * @param {Object} logger - The logger instance to use for logging.
+ */
+ setLogger (logger) {
+ this.logger = logger
+ }
+
+ /**
+ * Makes an API call to the specified endpoint with the given request parameters.
+ *
+ * @param {string} endpoint - The API endpoint to call.
+ * @param {Object} requestParams - The parameters for the API request.
+ * @param {Object} callParams - Additional parameters for the call.
+ * @param {boolean} [callParams.requireAuth=false] - Whether the call requires authentication.
+ * @returns {Promise