Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions packages/dota/src/dota/NeutralItemTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,30 @@ import { getRedisNumberValue } from '../utils/index.js'
import type { GSIHandlerType } from './GSIHandlerTypes.js'
import { say } from './say.js'

interface TierTime {
export interface TierTime {
tier: number
/** Minutes until this tier drops in a normal game (updated for patch 7.41) */
normalTime: number
/** Minutes until this tier drops in a turbo game (half of normalTime) */
turboTime: number
}

/**
* Neutral item tier availability times (Dota 2 patch 7.41).
* Tier 1 now starts at game start; higher tiers keep their current timings.
* Turbo times are exactly half of normal times.
*/
export const NEUTRAL_ITEM_TIER_TIMES: TierTime[] = [
{ tier: 1, normalTime: 0, turboTime: 0 },
{ tier: 2, normalTime: 15, turboTime: 7.5 },
{ tier: 3, normalTime: 25, turboTime: 12.5 },
{ tier: 4, normalTime: 35, turboTime: 17.5 },
{ tier: 5, normalTime: 60, turboTime: 30 },
]

export class NeutralItemTimer {
private notifiedTiers = new Set<number>()
//5, 15, 25, 35 and 60 - 7.38 patch
private readonly tierTimes: TierTime[] = [
{ tier: 1, normalTime: 5, turboTime: 2.5 },
{ tier: 2, normalTime: 15, turboTime: 7.5 },
{ tier: 3, normalTime: 25, turboTime: 12.5 },
{ tier: 4, normalTime: 35, turboTime: 17.5 },
{ tier: 5, normalTime: 60, turboTime: 30 },
]
private readonly tierTimes: TierTime[] = NEUTRAL_ITEM_TIER_TIMES

// Track the last game time checked to avoid spam
private lastCheckedTime = 0
Expand Down
103 changes: 103 additions & 0 deletions packages/dota/src/dota/__tests__/NeutralItemTimer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'bun:test'

Check failure on line 1 in packages/dota/src/dota/__tests__/NeutralItemTimer.test.ts

View workflow job for this annotation

GitHub Actions / test

assist/source/organizeImports

The imports and exports are not sorted.

import { NEUTRAL_ITEM_TIER_TIMES } from '../NeutralItemTimer.js'

/**
* Tests confirming neutral item tier availability times are valid
* for Dota 2 patch 7.41 (Tier 1 now starts at 0:00).
* Turbo times are exactly half of normal times.
*/
describe('Neutral Item Tier Times (patch 7.41)', () => {
it('has exactly 5 tiers', () => {
expect(NEUTRAL_ITEM_TIER_TIMES).toHaveLength(5)
})

it('tiers are numbered 1 through 5', () => {
const tierNumbers = NEUTRAL_ITEM_TIER_TIMES.map((t) => t.tier)
expect(tierNumbers).toEqual([1, 2, 3, 4, 5])
})

it('Tier 1 spawns at 0 minutes in normal mode', () => {
const tier1 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 1)
expect(tier1?.normalTime).toBe(0)
})

it('Tier 2 spawns at 15 minutes in normal mode', () => {
const tier2 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 2)
expect(tier2?.normalTime).toBe(15)
})

it('Tier 3 spawns at 25 minutes in normal mode', () => {
const tier3 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 3)
expect(tier3?.normalTime).toBe(25)
})

it('Tier 4 spawns at 35 minutes in normal mode', () => {
const tier4 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 4)
expect(tier4?.normalTime).toBe(35)
})

it('Tier 5 spawns at 60 minutes in normal mode', () => {
const tier5 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 5)
expect(tier5?.normalTime).toBe(60)
})

it('turbo times are exactly half of normal times for all tiers', () => {
for (const tier of NEUTRAL_ITEM_TIER_TIMES) {
expect(tier.turboTime).toBe(tier.normalTime / 2)
}
})

it('Tier 1 spawns at 0 minutes in turbo mode', () => {
const tier1 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 1)
expect(tier1?.turboTime).toBe(0)
})

it('Tier 5 spawns at 30 minutes in turbo mode', () => {
const tier5 = NEUTRAL_ITEM_TIER_TIMES.find((t) => t.tier === 5)
expect(tier5?.turboTime).toBe(30)
})

it('normal times are in ascending order', () => {
const times = NEUTRAL_ITEM_TIER_TIMES.map((t) => t.normalTime)
const sorted = [...times].sort((a, b) => a - b)
expect(times).toEqual(sorted)
})
})

describe('Roshan respawn timer constants', () => {
/**
* Roshan respawn timing in Dota 2 (unchanged since patch 7.24):
* - Minimum: 8 minutes (480 seconds) after death
* - Maximum: 11 minutes (660 seconds) after death
* - Turbo: half of normal (4 min to 5.5 min)
*/
const ROSHAN_MIN_SECONDS = 8 * 60
const ROSHAN_MAX_SECONDS = 11 * 60

it('Roshan minimum respawn time is 8 minutes (480 seconds)', () => {
expect(ROSHAN_MIN_SECONDS).toBe(480)
})

it('Roshan maximum respawn time is 11 minutes (660 seconds)', () => {
expect(ROSHAN_MAX_SECONDS).toBe(660)
})

it('Roshan turbo minimum respawn time is 4 minutes (240 seconds)', () => {
expect(ROSHAN_MIN_SECONDS / 2).toBe(240)
})

it('Roshan turbo maximum respawn time is 5.5 minutes (330 seconds)', () => {
expect(ROSHAN_MAX_SECONDS / 2).toBe(330)
})
})

describe('Aegis expiration timer', () => {
/**
* Aegis of the Immortal lasts 5 minutes after pickup.
*/
it('Aegis expires after 5 minutes (300 seconds)', () => {
const AEGIS_EXPIRE_SECONDS = 5 * 60
expect(AEGIS_EXPIRE_SECONDS).toBe(300)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ eventHandler.registerEvent(`event:${DotaEventTypes.AegisPickedUp}`, {
const gameTimeDiff =
(dotaClient.client.gsi?.map?.game_time ?? event.game_time) - event.game_time

// expire for aegis in 5 minutes
// Aegis of the Immortal expires 5 minutes after pickup (unchanged since patch 7.33)
const expireS = 5 * 60 - gameTimeDiff
const expireTime = (dotaClient.client.gsi?.map?.clock_time ?? 0) + expireS

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ eventHandler.registerEvent(`event:${DotaEventTypes.BountyPickup}`, {
handler: async (dotaClient, event: DotaEvent) => {
if (!isPlayingMatch(dotaClient.client.gsi)) return
if (!dotaClient.client.stream_online) return
// Only announce bounty rune pickups during the initial spawn window (first 2 minutes).
// Bounty runes also respawn periodically, but we only track the opening contest.
if (Number(dotaClient.client.gsi?.map?.clock_time) > 120) return

const playingTeam =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ eventHandler.registerEvent(`event:${DotaEventTypes.RoshanKilled}`, {
const gameTimeDiff =
(dotaClient.client.gsi?.map?.game_time ?? event.game_time) - event.game_time

// min spawn for rosh in 5 + 3 minutes
let minS = 5 * 60 + 3 * 60 - gameTimeDiff
// max spawn for rosh in 5 + 3 + 3 minutes
let maxS = 5 * 60 + 3 * 60 + 3 * 60 - gameTimeDiff
// Roshan respawn window: 8 to 11 minutes after death (unchanged since patch 7.24)
// minS = 8 minutes (480 seconds), maxS = 11 minutes (660 seconds)
let minS = 8 * 60 - gameTimeDiff
let maxS = 11 * 60 - gameTimeDiff

// Check if the game mode is Turbo (23)
if (playingGameMode === 23) {
Expand Down
1 change: 1 addition & 0 deletions packages/dota/src/dota/lib/checkMidas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async function checkMidasIterator(client: SocketClient) {
const currentTime = new Date().getTime()
const passiveMidasThreshold = 10000

// Hand of Midas has 2 charges since patch 7.38. Both charges full means player is not using it.
if (midasCharges === 2 && !passiveMidasData.told && !passiveMidasData.firstNoticedPassive) {
// Set the time when passive midas was first noticed
await redisClient.client.json.set(`${token}:passiveMidas`, '$', {
Expand Down
Loading