EasterEgg/HexPlatforms.Defense.cs

HexPlatforms partial class implementing the altar defense step for a game mode called BASALT. It manages host and mirrored state for a timed defense where waves of special zombies spawn targeting the altar, tracks hits, spawns/despawns wave and shriekers, updates visuals (altar glow and zombie eye color), provides console commands to control/tune the defense, and syncs state via NZNet.

NetworkingFile AccessNative Interop
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// BASALT — STEP 5, THE ALTAR, ITS SECOND PHASE: THE ALTAR DEFENSE (named by the user, 2026-09-26). Setting the cursed flame
/// on the altar wakes a wave: for a minute zombies without end — at the top speed tier — pour in from the spawns nearest
/// the altar, with a Shrieker now and then, and they go for the ALTAR, not the players. Ten hits on it fail the defense:
/// the wave is gone, and so is the flame, until the next round brings it back over tile 1 to be carried here again. A
/// minute held: the flame burns light blue, and can be taken up again. While it runs the round cannot end, every walker's
/// eyes burn purple instead of blue, and zombies pay little — a kill 10, a hit 1. Asked for as *"when we place the flame
/// there, an unlimited amount of
/// really fast zombies will spawn for 2 minutes, the round never ends during this time — these zombies will not target
/// the players, instead they will target the altar, if the altar takes 10 hits the step is failed, the extra zombies
/// despawn and i need to go pick up the flame from its spawn location in the next round and try again — if the players
/// manage to protect the altar for 2 minutes the flame becomes light blue and the player can pick it up again — during
/// this infinite wave of zombies, shriekers also spawn occasionally — also during this step all zombies get purple eyes
/// instead of blue eyes"*; then *"reduce the time to 1 minute instead of 2 — make it so during this 1 minute, zombie kills
/// award only 10 points, and shots award only 1 point"*.
///
/// ⛔ IT PAYS LITTLE, AND FLAT (<see cref="DefensePoints"/>): every zombie's kill 10 and every hit 1 while it runs — the
/// round's own zombies as well as the wave's — after Double Points, the kill bonuses and the bounty, not before: "only"
/// is what is paid. A wave without end must not be a points farm. The augments that pay a kill on their own — Deadshot's
/// Trophy, Vigor's Spoils — pay nothing while it runs, for the same reason.
///
/// ⛔ THE WAVE: walkers flagged `ZombieAI.AltarWave`, spawned at the spawns nearest the altar that are open this round.
/// - Their only target is the altar (`ZombieAI.GetTargetables`), and their swing lands on it, not on anyone
///   (`ZombieAI.DoAttackDamage` → <see cref="AltarHit"/>). They spawn at the top speed tier, as Misery's do.
/// - "Unlimited" is a stream, not a crowd: one every <see cref="WaveEvery"/> seconds while fewer than
///   <see cref="WaveMaxAlive"/> of them live — each one killed is replaced — for as long as it runs.
/// - ⚠️ ITS SHRIEKERS HUNT THE PLAYERS, AS EVERY SHRIEKER DOES — A CHOICE. A Shrieker's scream kills the zombies round it
///   and throws the players back (`ShriekerZombie.Pulse`): sent at the altar it would scream there and clear the wave for
///   the defenders; after them, it throws them off the altar. One every <see cref="ShriekerEvery"/> seconds, two at most.
/// - ⚠️ WHEN IT ENDS, EITHER WAY, EVERY ONE OF THE WAVE DESPAWNS — the Shriekers too. Asked for on a failure; on a win it
///   is a choice: the wave was the altar's, and it has nothing left to go for.
/// - The round's own zombies go on as ever, and hunt the players.
///
/// ⛔ THE ROUND IS HELD OPEN WHILE IT RUNS (<see cref="HoldsRound"/>, `RoundManager`'s round clear), however few are left.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the DEFENSE — running, held or not begun — its clock and the altar's HITS are HOST state; the defense and the hits
///   are MIRRORED (`NZNet.AltarDefenseState`) on every change and to a joiner, and each machine dresses from them: the
///   engravings' glow, dimming as the altar is hit; the flame's colour; the walkers' purple eyes (`ZombieEyes`);
/// - the WAVE is the host's: its zombies are spawned there and reach everyone as zombies do (`ZombieCommands.SpawnAt`),
///   with their flag in the spawn, so a puppet sprints as its original does.
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ the state ════════════════════════════════════════════════════════════════════════════

	/// <summary>Where the altar's defense stands: not begun — or failed — running, or held, the flame light blue.</summary>
	public enum Defense { None, Running, Won }

	/// <summary>HOST — the defense, and how many hits the altar has taken in it.</summary>
	Defense _defense;
	int _altarHits;

	/// <summary>MIRROR — the same, as this machine was told (`NZNet.AltarDefenseState`).</summary>
	Defense _defenseShown;
	int _altarHitsShown;

	/// <summary>HOST — the defense and the hits, for `NZNet.PushState` to replay to a joiner.</summary>
	public static int DefenseState => Instance.IsValid() ? (int)Instance._defense : 0;
	public static int AltarHitsState => Instance.IsValid() ? Instance._altarHits : 0;

	/// <summary>
	/// HOST — does the altar's defense hold the round open? `RoundManager` asks before it clears one: *"the round never ends
	/// during this time"*.
	/// </summary>
	public static bool HoldsRound => Instance.IsValid() && Instance._defense == Defense.Running;

	/// <summary>Is the flame light blue — the altar held? HOST, and as this machine was told.</summary>
	bool FlameBlue => _defense == Defense.Won;
	bool FlameBlueShown => _defenseShown == Defense.Won;

	/// <summary>HOST — the altar, as its wave's target, while the defense runs; null otherwise. `ZombieAI.GetTargetables` asks.</summary>
	public static GameObject AltarTarget
	{
		get
		{
			var m = Instance;
			return m.IsValid() && m._defense == Defense.Running && m._altarGo.IsValid() ? m._altarGo : null;
		}
	}

	/// <summary>Is this the altar? `ZombieAI.DoAttackDamage` asks, to hand it the swing.</summary>
	public static bool IsAltar( GameObject go ) => go.IsValid() && Instance.IsValid() && go == Instance._altarGo;

	// ══ tuning ═══════════════════════════════════════════════════════════════════════════════

	static float? _defenseSeconds, _waveEvery, _shriekerEvery;
	static int? _altarHitsToFail, _waveMaxAlive, _waveSpawns;

	/// <summary>How long the altar must be held, in seconds: a minute — two, until the user asked for one.</summary>
	public static float DefenseSeconds { get => Math.Clamp( _defenseSeconds ?? 60f, 5f, 1800f ); set => _defenseSeconds = value; }

	/// <summary>How many hits fail it: 10.</summary>
	public static int AltarHitsToFail { get => Math.Clamp( _altarHitsToFail ?? 10, 1, 1000 ); set => _altarHitsToFail = value; }

	/// <summary>How often a zombie of the wave comes, in seconds, while there is room: 0.6.</summary>
	public static float WaveEvery { get => Math.Clamp( _waveEvery ?? 0.6f, 0.05f, 30f ); set => _waveEvery = value; }

	/// <summary>
	/// How many of the wave's walkers may be alive at once: 16 — the stream's crowd. ⚠️ THEY COUNT TOWARD THE ROUND'S OWN
	/// CAP ON THE LIVING, as every zombie does, so while they are many the round's own come slower.
	/// </summary>
	public static int WaveMaxAlive { get => Math.Clamp( _waveMaxAlive ?? 16, 1, 100 ); set => _waveMaxAlive = value; }

	/// <summary>How many of the open zombie spawns nearest the altar the wave comes from: 6.</summary>
	public static int WaveSpawns { get => Math.Clamp( _waveSpawns ?? 6, 1, 64 ); set => _waveSpawns = value; }

	/// <summary>How often a Shrieker comes with the wave, in seconds: 25 — twice in its minute — and at most <see cref="WaveShriekers"/> alive.</summary>
	public static float ShriekerEvery { get => Math.Clamp( _shriekerEvery ?? 25f, 3f, 600f ); set => _shriekerEvery = value; }
	const int WaveShriekers = 2;

	static int? _defenseKillPoints, _defenseHitPoints;

	/// <summary>What a zombie's kill pays while the defense runs: 10, flat.</summary>
	public static int DefenseKillPoints { get => Math.Clamp( _defenseKillPoints ?? 10, 0, 10000 ); set => _defenseKillPoints = value; }

	/// <summary>What a hit on a zombie pays while the defense runs: 1, flat.</summary>
	public static int DefenseHitPoints { get => Math.Clamp( _defenseHitPoints ?? 1, 0, 10000 ); set => _defenseHitPoints = value; }

	/// <summary>
	/// Does the altar's defense run, so zombies pay little? EVERY machine, from the mirrored defense — the host's mirror is its
	/// own state the moment it is sent — because the augments that pay a kill on their own run on the KILLER's machine
	/// (`AugmentEffects.OnZombieKilled` relays), where the host's state is not.
	/// </summary>
	public static bool DefensePoints => Instance.IsValid() && Instance._defenseShown == Defense.Running;

	/// <summary>Is the altar's defense running, as this machine was told? `EggMusic` plays the challenge's track while it does.</summary>
	public static bool DefenseMusic => Instance.IsValid() && OnBasalt && Instance._defenseShown == Defense.Running;

	// ══ the wave ═════════════════════════════════════════════════════════════════════════════

	/// <summary>HOST — the clock, and when the next of the wave and the next Shrieker come.</summary>
	TimeUntil _defenseLeft, _waveNext, _shriekerNext;

	/// <summary>HOST — the wave's living: its walkers and its Shriekers, to despawn when it ends.</summary>
	List<ZombieAI> _wave;

	/// <summary>HOST — said once that the wave had nowhere to come from.</summary>
	bool _waveNowhere;

	/// <summary>
	/// The flame is on the altar: the defense begins. HOST — `PlaceFlame`, the purple flame set down. The clock starts, the
	/// wave comes at once, the first Shrieker at <see cref="ShriekerEvery"/>.
	/// </summary>
	void StartDefense()
	{
		EndWave();
		_defense = Defense.Running;
		_altarHits = 0;
		_defenseLeft = DefenseSeconds;
		_waveNext = 0f;
		_shriekerNext = ShriekerEvery;
		_waveNowhere = false;
		SendDefense();
		Log.Info( $"[nz-hex] ⚔ THE ALTAR DEFENSE — hold the altar for {DefenseSeconds:0}s: a wave without end comes for it, and"
			+ $" {AltarHitsToFail} hits fail it. The round is held open, and a kill pays {DefenseKillPoints}, a hit {DefenseHitPoints}" );
	}

	/// <summary>
	/// HOST — from `OnUpdate`, every frame, while the defense runs: the clock, and the wave kept coming. A game that has
	/// ended, or a flame no longer on the altar, ends it quietly.
	/// </summary>
	void WatchDefense()
	{
		if ( _defense != Defense.Running ) return;

		if ( !OnBasalt || !_flamePlaced || RoundManager.Instance is { State: RoundState.GameOver } )
		{
			EndDefense( Defense.None );
			return;
		}

		_wave ??= new();
		_wave.RemoveAll( z => !z.IsValid() || z.State == ZombieState.Dead );

		if ( _defenseLeft <= 0f )
		{
			WinDefense();
			return;
		}

		if ( _waveNext <= 0f )
		{
			if ( _wave.Count( z => z.AltarWave ) < WaveMaxAlive ) SpawnWave( shrieker: false );
			_waveNext = WaveEvery;
		}

		if ( _shriekerNext <= 0f )
		{
			if ( _wave.Count( z => !z.AltarWave ) < WaveShriekers ) SpawnWave( shrieker: true );
			_shriekerNext = ShriekerEvery;
		}
	}

	/// <summary>
	/// One of the wave, at one of the <see cref="WaveSpawns"/> open zombie spawns nearest the altar, at random: a walker for
	/// the altar, or a Shrieker for the players. HOST.
	/// </summary>
	void SpawnWave( bool shrieker )
	{
		var round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		var near = RoundManager.EligibleSpawnsFor( round )
			.OrderBy( s => s.Position.Distance( AltarAt ) )
			.Take( WaveSpawns )
			.ToList();

		if ( near.Count == 0 )
		{
			if ( !_waveNowhere ) Log.Warning( "[nz-hex] ⚔ the altar's wave has nowhere to come from: no zombie spawn is open this round (nz_links)" );
			_waveNowhere = true;
			return;
		}

		var variant = shrieker ? SpecialEnemies.VariantFor( SpecialEnemies.Shrieker ) : null;
		if ( shrieker && variant is null ) return;

		var point = near[Game.Random.Next( near.Count )];
		var z = ZombieCommands.SpawnAt( Scene, point.Position, variant, altarWave: !shrieker );
		if ( z is null ) return;

		z.WorldRotation = point.Rotation;
		(_wave ??= new()).Add( z );
	}

	/// <summary>
	/// Every one of the wave gone — despawned, not killed: no points, no drops. HOST. Any a hotload left untracked are known
	/// by their flag.
	/// </summary>
	void EndWave()
	{
		var gone = new HashSet<ZombieAI>();
		if ( _wave is not null ) gone.UnionWith( _wave.Where( z => z.IsValid() ) );
		gone.UnionWith( ZombieAI.All.Where( z => z.IsValid() && z.AltarWave ) );
		_wave?.Clear();

		foreach ( var z in gone )
			if ( z.GameObject.IsValid() ) z.GameObject.Destroy();

		if ( gone.Count > 0 ) Log.Info( $"[nz-hex] ⚔ the altar's wave despawned — {gone.Count} zombie(s)" );
	}

	/// <summary>
	/// A zombie of the wave landed a swing on the altar. HOST — `ZombieAI.DoAttackDamage`, which has played its impact already.
	/// </summary>
	public static void AltarHit()
	{
		if ( NZGame.IsClient ) return;

		var m = Instance;
		if ( m.IsValid() ) m.TakeAltarHit();
	}

	/// <summary>The rule for a hit. HOST — apart from the hook, so the selftest can walk it. The tenth fails the defense.</summary>
	void TakeAltarHit()
	{
		if ( _defense != Defense.Running ) return;

		_altarHits++;
		SendDefense();
		Log.Info( $"[nz-hex] ⚔ the altar is hit — {_altarHits} of {AltarHitsToFail}" );

		if ( _altarHits >= AltarHitsToFail ) FailDefense();
	}

	/// <summary>
	/// A minute held: the flame burns light blue on the altar, and can be taken up again. HOST. The wave despawns, and the
	/// step-done clicking plays.
	/// </summary>
	void WinDefense()
	{
		EndWave();
		_defense = Defense.Won;
		SendDefense();
		if ( !Testing ) DoneCueLater();
		Fanfare( 7 );
		Log.Info( $"[nz-hex] ✦ THE ALTAR HELD — {DefenseSeconds:0}s, {_altarHits} of {AltarHitsToFail} hits taken. The cursed flame burns"
			+ " light blue, and can be taken up again" );
	}

	/// <summary>
	/// Ten hits: the defense fails. HOST. The wave despawns, the flame goes from the altar and from everywhere — the next
	/// round brings it back over tile 1, where it first floated (<see cref="ReturnFlame"/>) — and the power-down sounds at
	/// the altar. The shield stays open: the flame is only to be carried here again.
	/// </summary>
	void FailDefense()
	{
		EndWave();
		var hits = _altarHits;
		_defense = Defense.None;
		_altarHits = 0;
		_flamePlaced = false;
		_flameLost = true;
		SendFlame();
		SendDefense();
		Cue( FailCue, AltarTop );
		Log.Info( $"[nz-hex] ⚔ THE ALTAR FELL — {hits} hits. The wave is gone, and the cursed flame with it: the next round brings it"
			+ " back over tile 1, to carry here again" );
	}

	/// <summary>The defense over without a winner — a new game, a flame taken off by hand, a game ended. HOST. Quiet.</summary>
	void EndDefense( Defense to )
	{
		if ( _defense == to && _altarHits == 0 && (_wave is null || _wave.Count == 0) && !ZombieAI.All.Any( z => z.IsValid() && z.AltarWave ) )
			return;

		EndWave();
		_defense = to;
		_altarHits = 0;
		SendDefense();
	}

	void SendDefense() => NZNet.AltarDefenseState( (int)_defense, _altarHits );

	/// <summary>
	/// The defense and the hits. EVERY machine — `NZNet.AltarDefenseState`: the walkers' eyes purple while it runs, the
	/// engravings in the flame's colour, dimming with every hit, and the flame purple or light blue.
	/// </summary>
	public void ApplyDefense( int state, int hits )
	{
		_defenseShown = (Defense)Math.Clamp( state, 0, 2 );
		_altarHitsShown = Math.Max( 0, hits );
		ZombieEyes.SetDefense( OnBasalt && _defenseShown == Defense.Running );
		DressAltar();
		BuildRewardTile();
	}

	// ══ the altar's glow ═════════════════════════════════════════════════════════════════════

	/// <summary>The light blue the flame burns once the altar is held — its glow, its light and the engravings'.</summary>
	static Color FlameBlueHue => new( 0.35f, 0.72f, 1f );

	static float? _altarGlowDrive;

	/// <summary>How hard the engravings are driven in the flame's colour: 2.4, the tiles' lights' order.</summary>
	public static float AltarGlowDrive { get => Math.Clamp( _altarGlowDrive ?? 2.4f, 0.1f, 20f ); set => _altarGlowDrive = value; }

	/// <summary>`nz_hex_altar_glow`'s copies of the map's light, by colour: a tinted copy each, never white001 itself.</summary>
	static Dictionary<string, Material> _glows;

	/// <summary>
	/// A copy of white001 in this hue at this drive — ⚠️ A COPY, as `MaterialFor` makes: tinting white001 would turn every
	/// light strip in the map. The tint is written on every call, so a dimming or a retune takes at once.
	/// </summary>
	static Material GlowMaterial( string key, Color hue, float drive )
	{
		var white = Material.Load( LightMaterial );
		if ( white is null ) return null;

		_glows ??= new();
		if ( !_glows.TryGetValue( key, out var m ) || m is null )
			_glows[key] = m = white.CreateCopy( $"nz_hex_altar_{key}" );

		m.Set( "g_vColorTint", new Vector3( hue.r * drive, hue.g * drive, hue.b * drive ) );
		return m;
	}

	/// <summary>
	/// The engravings as the flame on the altar makes them: in its colour while it burns there — purple, fading toward a
	/// third with every hit the altar takes while defended; light blue once held — and the map's white otherwise. Asked for
	/// as *"when i place the orb there, make the engravings shine in the same color as the flame"*. LOCAL — the pillar keeps
	/// its stone: only the engravings, its children, are overridden.
	/// </summary>
	void DressAltar()
	{
		if ( !_altarGo.IsValid() ) return;

		Material glow = null;
		if ( _flamePlacedShown )
		{
			var blue = FlameBlueShown;
			var worn = _defenseShown == Defense.Running
				? 1f - 0.65f * Math.Clamp( _altarHitsShown / (float)AltarHitsToFail, 0f, 1f )
				: 1f;
			glow = GlowMaterial( blue ? "blue" : "purple", blue ? FlameBlueHue : CursedPurple, AltarGlowDrive * worn );
		}

		foreach ( var r in _altarGo.Components.GetAll<ModelRenderer>( FindMode.EverythingInDescendants ) )
			r.MaterialOverride = glow;
	}

	// ══ commands ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// `nz_hex_defense [start|win|fail|stop|hit]` — HOST: the altar's defense as it stands. `start` sets the cursed flame on the
	/// altar by hand and begins it; `win` ends it held, as its minute would; `fail` ends it fallen, as ten hits would; `stop`
	/// ends it quietly, the flame still purple on the altar; `hit` is one hit on the altar, as a zombie's swing is.
	/// </summary>
	[ConCmd( "nz_hex_defense" )]
	public static void DefenseCmd( string what = "" )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }

		switch ( what.Trim().ToLowerInvariant() )
		{
			case "":
				break;

			case "start":
				if ( m._bonfire != BonfireOut ) { Log.Warning( "[nz-hex] the fire is not out, so there is no cursed flame — nz_hex_skipto torch first" ); return; }
				m._flameCarrier = "";
				m._flameLost = false;
				m._flamePlaced = true;
				m.SendFlame();
				m.StartDefense();
				break;

			case "win":
				if ( m._defense != Defense.Running ) { Log.Warning( "[nz-hex] the defense is not running — nz_hex_defense start" ); return; }
				m.WinDefense();
				break;

			case "fail":
				if ( m._defense != Defense.Running ) { Log.Warning( "[nz-hex] the defense is not running — nz_hex_defense start" ); return; }
				m._altarHits = AltarHitsToFail - 1;
				m.TakeAltarHit();
				break;

			case "stop":
				m.EndDefense( Defense.None );
				break;

			case "hit":
				m.TakeAltarHit();
				break;

			default:
				Log.Warning( "[nz-hex] nz_hex_defense start, win, fail, stop or hit — or nothing, to see where it stands" );
				return;
		}

		var alive = m._wave?.Count( z => z.IsValid() && z.State != ZombieState.Dead ) ?? 0;
		Log.Info( m._defense switch
		{
			Defense.Running => $"[nz-hex] ⚔ the altar's defense: {(float)m._defenseLeft:0}s left, {m._altarHits} of {AltarHitsToFail} hits,"
				+ $" {alive} of the wave alive · the round is held open",
			Defense.Won => "[nz-hex] the altar is held — the cursed flame burns light blue" + ( m._flamePlaced ? " on it" : ", carried" ),
			_ => "[nz-hex] the altar's defense: not running" + ( m._flamePlaced ? " (the flame is on the altar)" : "" ),
		} );
	}

	/// <summary>
	/// `nz_hex_defense_tune [seconds] [hits] [every] [maxAlive] [spawns] [shriekerEvery]` — HOST: the defense's numbers: how
	/// long it must be held, how many hits fail it, how often the wave comes and how many of it may live, from how many of
	/// the spawns nearest the altar, and how often a Shrieker. Bare, it prints them. Until a restart.
	/// </summary>
	[ConCmd( "nz_hex_defense_tune" )]
	public static void DefenseTuneCmd( float seconds = 0f, int hits = 0, float every = 0f, int maxAlive = 0, int spawns = 0, float shriekerEvery = 0f )
	{
		if ( seconds > 0f ) DefenseSeconds = seconds;
		if ( hits > 0 ) AltarHitsToFail = hits;
		if ( every > 0f ) WaveEvery = every;
		if ( maxAlive > 0 ) WaveMaxAlive = maxAlive;
		if ( spawns > 0 ) WaveSpawns = spawns;
		if ( shriekerEvery > 0f ) ShriekerEvery = shriekerEvery;

		Log.Info( $"[nz-hex] ⚔ the altar's defense: hold it {DefenseSeconds:0}s, {AltarHitsToFail} hits fail it; the wave comes every"
			+ $" {WaveEvery:0.##}s while under {WaveMaxAlive} live, from the {WaveSpawns} open spawns nearest the altar; a Shrieker every"
			+ $" {ShriekerEvery:0}s, {WaveShriekers} at most" );
	}

	/// <summary>
	/// `nz_hex_defense_points [kill] [hit]` — what a zombie's kill and a hit pay while the altar's defense runs: 10 and 1.
	/// Bare, it prints them. On each machine it is read on — the host's for the award, the killer's for the augments' — until
	/// a restart.
	/// </summary>
	[ConCmd( "nz_hex_defense_points" )]
	public static void DefensePointsCmd( int kill = -1, int hit = -1 )
	{
		if ( kill >= 0 ) DefenseKillPoints = kill;
		if ( hit >= 0 ) DefenseHitPoints = hit;

		Log.Info( $"[nz-hex] ⚔ while the altar's defense runs, a zombie's kill pays {DefenseKillPoints} and a hit {DefenseHitPoints},"
			+ " flat — no Double Points, no kill bonuses" + ( DefensePoints ? " — running now" : "" ) );
	}

	/// <summary>
	/// `nz_hex_altar_glow [drive]` — how hard the engravings shine in the flame's colour: 2.4. On this machine, until a
	/// restart.
	/// </summary>
	[ConCmd( "nz_hex_altar_glow" )]
	public static void AltarGlowCmd( float drive = 0f )
	{
		if ( drive > 0f ) AltarGlowDrive = drive;

		var m = Instance;
		if ( m.IsValid() ) m.DressAltar();

		Log.Info( $"[nz-hex] the altar's engravings shine at {AltarGlowDrive:0.##} in the flame's colour while it burns there" );
	}
}