EasterEgg/HexPlatforms.Altar.cs

Part of the HexPlatforms manager for the Basalt Easter egg, implements the altar pillar, its geometry and engravings, the cursed-flame placement rules and host/client command handlers. Builds a local pillar model, handles local aim/use checks, host-side placement validation, state tracking and console commands to manipulate the altar and flame parameters.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// BASALT — STEP 5, THE ALTAR (named by the user, 2026-09-26). A slim hexagonal pillar stands in the middle of the cyan
/// shield's column, where the user marked the spot with an ASP wall buy — inside the shield, so it is reached only once
/// the shield lock is open — and the cursed flame is set on it. Asked for as *"the altar is a slim hexagonal pillar at
/// about 2/3 of a zombie's height that stands upright in that place and has some engravings on it, these are just for
/// ambiance, and they are white like the lights — i need to place the cursed flame there which i can only do after i
/// disable the combine wall"*.
///
/// ⛔ THE PILLAR: 48u tall — two thirds of a zombie's 72 (`ZombieAI.BodyHeight`) — and 16u across its flats, in the stepping
/// stones' own concrete, turned as the column and the tiles are, points north and south. Its engravings are strokes of the
/// map's own white light (`lights/white001`, the lights'): a double band round its foot and its head, a motif on each side
/// — a column of chevrons, a sun, a zigzag, twice round — and a hexagon on its top where the flame goes. ⚠️ NONE OF THEM
/// IS A COLOR RINGS GLYPH: they mean nothing, and a glyph would read as a clue.
///
/// ⛔ SETTING THE FLAME ON IT: the carrier, within reach and looking at the altar with the shield lock open, sees "Press E -
/// Place the cursed flame" (`UsePrompt.ForAltar`), and E sets it there (`NZPlayer.TickUse`): it leaves their hands and
/// burns over the altar's top, and the engravings shine in its colour. Placing it plays the pickup's sound at the altar,
/// and the step-done clicking after — and begins THE ALTAR DEFENSE (`HexPlatforms.Defense.cs`): a minute of a wave
/// that goes for the altar. Held, the flame burns light blue and can be taken up again; fallen, it is gone until the next
/// round brings it back over tile 1.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the PRESS is the presser's machine's: it asks the host (`NZNet.CursedFlamePlace`), which knows who from the call;
/// - WHETHER THE FLAME IS ON THE ALTAR is HOST state, MIRRORED with its carrier (`NZNet.CursedFlameState`) and to a joiner;
/// - the ALTAR itself is LOCAL: every machine builds its own, all game, whatever the steps — it is part of the room.
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ where it is ══════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// The altar's foot: the middle of the cyan shield's column (<see cref="ShieldCentre"/>, 1792 608), on its floor at 1196 —
	/// where the ASP wall buy the user placed stood, 0.3u off that middle, flat on the floor (surveyed 2026-09-26).
	/// </summary>
	static Vector3 AltarAt => new( ShieldCentre.x, ShieldCentre.y, 1196f );

	/// <summary>How tall the altar is, and how far from its middle each flat lies: 48u — two thirds of a zombie — and 8u.</summary>
	const float AltarHeight = 48f, AltarApothem = 8f;

	/// <summary>How far from its middle each corner lies: a regular hexagon's, 2/√3 of the flats' — and so each side's width.</summary>
	static float AltarCorner => AltarApothem * 2f / MathF.Sqrt( 3f );

	/// <summary>The middle of its top, where the flame is set.</summary>
	static Vector3 AltarTop => AltarAt + Vector3.Up * AltarHeight;

	/// <summary>
	/// How far a carrier's feet may be from the altar's foot to set the flame on it: 90u, an arm's length and a step. ⚠️ THE
	/// SHIELD'S FACES ARE ONLY 60U OUT, so from outside a standing shield the altar is in reach — which is why the rule asks
	/// for the lock open as well, not only for reach.
	/// </summary>
	const float AltarReach = 90f;

	/// <summary>How near its axis a look must pass to be at the altar, in units.</summary>
	const float AltarAimRadius = 14f;

	/// <summary>The altar's corners, round its middle, counter-clockwise from above from the north point — the tiles' order.</summary>
	static Vector2[] AltarCorners()
	{
		float a = AltarApothem, r = AltarCorner;
		return new[] { new Vector2( 0f, r ), new Vector2( -a, r * 0.5f ), new Vector2( -a, -r * 0.5f ),
			new Vector2( 0f, -r ), new Vector2( a, -r * 0.5f ), new Vector2( a, r * 0.5f ) };
	}

	// ══ the flame on it ═════════════════════════════════════════════════════════════════════

	static float? _altarFlameRadius, _altarFlameHeight, _altarFlameScale, _altarFlameLight;
	static int? _altarFlames;

	/// <summary>The flame on the altar: a sphere of 12u radius — between the loose one's 22 and the held one's 6 — …</summary>
	public static float AltarFlameRadius { get => Math.Clamp( _altarFlameRadius ?? 12f, 2f, 80f ); set => _altarFlameRadius = value; }

	/// <summary>
	/// …its middle 17u over the altar's top, so its lowest flames start some 5u off the stone — lowered from 22 by request,
	/// *"lower the flame placement height on the altar a little bit"*…
	/// </summary>
	public static float AltarFlameHeight { get => Math.Clamp( _altarFlameHeight ?? 17f, 0f, 200f ); set => _altarFlameHeight = value; }

	/// <summary>…of 12 flames, each 0.8 of a burning body's, and a light of 2.</summary>
	public static int AltarFlames { get => Math.Clamp( _altarFlames ?? 12, 1, 48 ); set => _altarFlames = value; }
	public static float AltarFlameScale { get => Math.Clamp( _altarFlameScale ?? 0.8f, 0.05f, 10f ); set => _altarFlameScale = value; }
	public static float AltarFlameLight { get => Math.Clamp( _altarFlameLight ?? 2f, 0f, 20f ); set => _altarFlameLight = value; }

	/// <summary>Where the flame on the altar floats from.</summary>
	static Vector3 AltarFlameHome => AltarTop + Vector3.Up * AltarFlameHeight;

	/// <summary>HOST — is the cursed flame on the altar? For good, until a new game.</summary>
	bool _flamePlaced;

	/// <summary>MIRROR — the same, as this machine was told (`NZNet.CursedFlameState`).</summary>
	bool _flamePlacedShown;

	/// <summary>HOST — whether it is on the altar, for `NZNet.PushState` to replay to a joiner.</summary>
	public static bool FlamePlacedState => Instance.IsValid() && Instance._flamePlaced;

	/// <summary>
	/// Is this player carrying the cursed flame, and looking at the altar, near enough, with the shield lock open? LOCAL —
	/// the prompt's test and the use key's, one question for both (`UsePrompt.ForAltar`, `NZPlayer.TickUse`). The look is
	/// the camera's ray passing near the altar's axis, from its foot up to the flame's place over it, as the altar's
	/// collider is not the whole of what a look at it means.
	/// </summary>
	public static bool AltarAimed( NZPlayer player )
	{
		var m = Instance;
		if ( !m.IsValid() || !OnBasalt || !m._lockOpenShown || m._flamePlacedShown || CannotCarry( player ) ) return false;
		if ( !m.FlameCarried || m._flameCarrierShown != CarrierIdOf( player ) ) return false;
		if ( AltarAt.Distance( player.WorldPosition ) > AltarReach ) return false;

		var cam = m.Scene.Camera;
		if ( !cam.IsValid() ) return false;

		// spheres up the axis, 6u apart and 14u round, so together they are a column a look cannot slip between
		for ( var z = 0f; z <= AltarHeight + AltarFlameHeight; z += 6f )
			if ( RayMeetsSphere( cam.WorldPosition, cam.WorldRotation.Forward, AltarAt + Vector3.Up * z, AltarAimRadius ) )
				return true;

		return false;
	}

	/// <summary>E on the altar. LOCAL — the presser's machine asks the host, which decides (<see cref="HostPlaceFlame"/>).</summary>
	public static void PlaceCursedFlame( NZPlayer player )
	{
		if ( player.IsValid() ) NZNet.CursedFlamePlace();
	}

	/// <summary>
	/// Someone pressed E on the altar. HOST — `NZNet.CursedFlamePlace`, with `who` the caller's connection id as the call
	/// itself carries it, or "" for the host's own press.
	/// </summary>
	public static void HostPlaceFlame( string who )
	{
		if ( NZGame.IsClient ) return;

		var m = Instance;
		if ( !m.IsValid() ) return;

		var body = CarrierBodyOf( who );
		if ( !body.IsValid() && (string.IsNullOrEmpty( who ) || who == Connection.Local?.Id.ToString()) ) body = NZPlayer.Local;

		var refused = m.PlaceFlame( body );
		if ( refused != "" ) Log.Info( $"[nz-hex] the cursed flame is not set on the altar: {refused}" );
	}

	/// <summary>
	/// The rules for setting it on the altar. HOST — apart from the RPC, so the selftest can walk it. The fire must be out,
	/// the flame not there already, the shield lock open, and the one setting it its carrier, up and within reach of the
	/// altar (`anywhere` skips the reach, for the test). Returns why not, or "".
	/// </summary>
	string PlaceFlame( NZPlayer body, bool anywhere = false )
	{
		if ( _bonfire != BonfireOut ) return "the fire is not out";
		if ( _flamePlaced ) return "it burns on the altar already";
		if ( !_lockOpen ) return "the cyan shield stands round the altar — the lock is shut";
		if ( string.IsNullOrEmpty( _flameCarrier ) || CarrierIdOf( body ) != _flameCarrier ) return "they do not carry it";
		if ( CannotCarry( body ) ) return "they are down, out or gone";
		if ( !anywhere && AltarAt.Distance( body.WorldPosition ) > AltarReach + ReachSlack ) return "they are too far from the altar";

		_flameCarrier = "";
		_flamePlaced = true;
		SendFlame();
		if ( !Testing ) NZSound.PlayShared( NZSound.PickupSalvage, AltarTop );

		// ⚠️ THE PURPLE FLAME WAKES THE DEFENSE; the light blue one — the altar already held — only rests there again
		if ( _defense == Defense.Won )
		{
			Log.Info( $"[nz-hex] 🗿 {NameFor( body )} set the light blue flame back on the altar" );
			return "";
		}

		if ( !Testing ) DoneCueLater();
		Log.Info( $"[nz-hex] 🗿 THE ALTAR — {NameFor( body )} set the cursed flame on the altar" );

		// ⚠️ STEP 6 DONE: the word goes out for its banner (`EggProgress`); its fanfare plays nothing, the defense's music begins.
		// Again each time the purple flame is set back after a lost defense, which is the step done again.
		Fanfare( 6 );
		StartDefense();
		return "";
	}

	// ══ the pillar ══════════════════════════════════════════════════════════════════════════

	/// <summary>LOCAL — the altar: the pillar, its collider and its engravings, children of one object.</summary>
	GameObject _altarGo;

	/// <summary>
	/// How the altar is drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: the pillar and its engravings, 2026-09-26. A manager that has
	/// not built this layout builds the altar again — `OnUpdate` asks every frame — as the lock's `LockLayout` does.
	/// </summary>
	const int AltarLayout = 1;

	/// <summary>LOCAL — the layout this manager last built the altar in; 0 before it has.</summary>
	int _altarLaid;

	/// <summary>The altar's object, by name: how a sweep knows one a manager before this one left.</summary>
	const string AltarName = "Basalt altar";

	/// <summary>How wide an engraving's strokes are, in units, and how far proud of the stone they lie.</summary>
	const float EngraveStroke = 0.45f, EngraveProud = 0.3f;

	/// <summary>Say once that the stone did not load, not at every rebuild.</summary>
	static bool _altarMissing;

	/// <summary>The altar, on basalt, all game: built once, and again at a new layout. LOCAL.</summary>
	void BuildAltar()
	{
		_altarLaid = AltarLayout;
		ClearAltar();
		if ( !OnBasalt || !Scene.IsValid() ) return;

		var body = AltarModel( Material.Load( StoneMaterial ) );
		if ( body is null )
		{
			if ( !_altarMissing ) Log.Warning( $"[nz-hex] {StoneMaterial} did not load — the altar is not built" );
			_altarMissing = true;
			return;
		}

		var root = Scene.CreateObject();
		root.Name = AltarName;
		root.Flags |= GameObjectFlags.NotSaved;
		root.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		root.Tags.Add( PanelTag );
		root.WorldPosition = AltarAt;
		root.WorldRotation = Rotation.Identity;

		root.Components.Create<ModelRenderer>().Model = body;

		// ⚠️ SOLID, as a pillar is: a box round the hexagon, its corners the box's — a sliver more than the stone, never less
		var box = root.Components.Create<BoxCollider>();
		box.Scale = new Vector3( AltarApothem * 2f, AltarCorner * 2f, AltarHeight );
		box.Center = new Vector3( 0f, 0f, AltarHeight * 0.5f );

		// the engravings: each side's, in its plane, facing out of it; and the hexagon on its top
		var corners = AltarCorners();
		for ( var k = 0; k < 6; k++ )
		{
			var a = corners[k];
			var b = corners[(k + 1) % 6];
			var along = (b - a) * (1f / (b - a).Length);
			var n = new Vector3( along.y, -along.x, 0f );           // the edge turned right: out of the side
			var mid = (a + b) * 0.5f;

			AltarPart( root, $"engraving {k + 1}", IconModel( AltarSideShapes( k ), EngraveStroke, White, 1f ),
				new Vector3( mid.x, mid.y, AltarHeight * 0.5f ) + n * EngraveProud, Rotation.LookAt( n, Vector3.Up ) );
		}

		AltarPart( root, "engraving on its top", IconModel( AltarTopShapes(), EngraveStroke, White, 1f ),
			new Vector3( 0f, 0f, AltarHeight + EngraveProud ), Rotation.LookAt( Vector3.Up, Vector3.Forward ) );

		_altarGo = root;

		// in the flame's colour, if it burns there already (`HexPlatforms.Defense.cs`)
		DressAltar();
	}

	/// <summary>One of the altar's engravings: a child of it, placed and turned in its own frame, drawing this model.</summary>
	void AltarPart( GameObject root, string name, Model model, Vector3 at, Rotation facing )
	{
		if ( model is null ) return;

		var go = Scene.CreateObject();
		go.Name = name;
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.SetParent( root, false );
		go.LocalPosition = at;
		go.LocalRotation = facing;

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;
		r.RenderType = ModelRenderer.ShadowRenderType.Off;
	}

	/// <summary>The altar gone — and any a manager before this one left, found by name. LOCAL.</summary>
	void ClearAltar()
	{
		if ( _altarGo.IsValid() ) _altarGo.Destroy();
		_altarGo = null;

		if ( !Scene.IsValid() ) return;
		foreach ( var old in Scene.GetAllObjects( false ).Where( x => x.Tags.Has( PanelTag ) && x.Name == AltarName ).ToList() )
			old.Destroy();
	}

	/// <summary>
	/// The pillar as one model in the stones' concrete, from its foot at the object's origin: its six sides and its top,
	/// the texture laid as the map lays it on the stones — world-aligned, u along a side and v down, one repeat every
	/// 128u (`StoneUnits`) — for where its foot stands: the Altar's, unless another is given (the blue altar's,
	/// `HexPlatforms.BlueAltar.cs`). Null without the material.
	/// </summary>
	static Model AltarModel( Material stone, Vector3? foot = null )
	{
		if ( stone is null ) return null;

		var at = foot ?? AltarAt;
		var corners = AltarCorners();
		var verts = new List<Vertex>();
		var idx = new List<int>();
		float h = AltarHeight, uv = StoneUnits;
		var place = new Vector2( at.x, at.y );

		for ( var i = 0; i < 6; i++ )
		{
			var a = corners[i];
			var b = corners[(i + 1) % 6];
			var along = (b - a) * (1f / (b - a).Length);
			var n = new Vector3( along.y, -along.x, 0f );
			var t = new Vector3( along.x, along.y, 0f );

			var wa = a + place;
			var wb = b + place;
			float u0 = (wa.x * along.x + wa.y * along.y) / uv, u1 = (wb.x * along.x + wb.y * along.y) / uv;
			float vLo = -at.z / uv, vHi = -(at.z + h) / uv;

			var s = verts.Count;
			verts.Add( new Vertex( new Vector3( a.x, a.y, 0f ), n, t, new Vector4( u0, vLo, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( b.x, b.y, 0f ), n, t, new Vector4( u1, vLo, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( b.x, b.y, h ), n, t, new Vector4( u1, vHi, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( a.x, a.y, h ), n, t, new Vector4( u0, vHi, 0f, 0f ) ) );

			// the winding `HexSidePanels.Build` uses, which faces out
			idx.Add( s ); idx.Add( s + 1 ); idx.Add( s + 2 );
			idx.Add( s ); idx.Add( s + 2 ); idx.Add( s + 3 );
		}

		// its top: a fan from the middle, counter-clockwise from above, so it faces up
		var c = verts.Count;
		verts.Add( new Vertex( new Vector3( 0f, 0f, h ), Vector3.Up, Vector3.Forward, new Vector4( place.x / uv, place.y / uv, 0f, 0f ) ) );
		foreach ( var p in corners )
			verts.Add( new Vertex( new Vector3( p.x, p.y, h ), Vector3.Up, Vector3.Forward,
				new Vector4( (place.x + p.x) / uv, (place.y + p.y) / uv, 0f, 0f ) ) );
		for ( var i = 0; i < 6; i++ )
		{
			idx.Add( c ); idx.Add( c + 1 + i ); idx.Add( c + 1 + (i + 1) % 6 );
		}

		var r = AltarCorner;
		var bounds = new BBox( new Vector3( -r, -r, 0f ), new Vector3( r, r, h ) );
		var mesh = new Mesh( stone );
		mesh.CreateVertexBuffer( verts.Count, verts );
		mesh.CreateIndexBuffer( idx.Count, idx );

		// ⛔ SET BY HAND, AFTER THE BUFFERS: a runtime mesh gets empty bounds otherwise, and is culled (`HexSidePanels.Build`)
		mesh.Bounds = bounds;
		return Model.Builder.AddMesh( mesh ).WithViewBounds( bounds ).Create();
	}

	/// <summary>
	/// A side's engraving, in the stroke family, in units round the side's middle — u across it, the viewer's right, and v
	/// up, the altar spanning -24..24 and each side 9.2u wide: a double band at its foot and its head, and between them one
	/// of three motifs, side by side round the altar twice — a column of chevrons with a small sun at its head, a sun with
	/// its rays, and a zigzag rising. For the room, not a clue: none is a Color Rings glyph.
	/// </summary>
	static (string Kind, float[] P)[] AltarSideShapes( int side )
	{
		const float w = 4.45f;                                      // a band runs almost to each corner
		var s = new List<(string Kind, float[] P)>
		{
			("line", new[] { -w, -20f, w, -20f }), ("line", new[] { -w, -18.6f, w, -18.6f }),
			("line", new[] { -w, 20f, w, 20f }), ("line", new[] { -w, 18.6f, w, 18.6f }),
		};

		switch ( side % 3 )
		{
			case 0:                                                 // chevrons up a stem, a sun at its head
				s.Add( ("line", new[] { 0f, -15f, 0f, 9f }) );
				foreach ( var v in new[] { -11f, -5.5f, 0f, 5.5f } )
					s.Add( ("line", new[] { -2.3f, v - 2.3f, 0f, v, 2.3f, v - 2.3f }) );
				s.Add( ("ring", new[] { 0f, 12.6f, 2f }) );
				s.Add( ("dot", new[] { 0f, 12.6f, 0.6f }) );
				break;

			case 1:                                                 // a sun and its rays, a dot above and below
				s.Add( ("ring", new[] { 0f, 0f, 2.8f }) );
				s.Add( ("dot", new[] { 0f, 0f, 0.8f }) );
				// its rays: long up and down, shorter aslant, shortest across — a side is only 9.2u wide
				for ( var k = 0; k < 8; k++ )
				{
					var a = k * MathF.PI / 4f;
					float cx = MathF.Cos( a ), cy = MathF.Sin( a );
					var end = k % 4 == 2 ? 6f : k % 2 == 1 ? 5.2f : 4.2f;
					s.Add( ("line", new[] { cx * 3.6f, cy * 3.6f, cx * end, cy * end }) );
				}
				s.Add( ("dot", new[] { 0f, 11.5f, 0.7f }) );
				s.Add( ("dot", new[] { 0f, -11.5f, 0.7f }) );
				s.Add( ("line", new[] { 0f, 13.6f, 0f, 16f }) );
				s.Add( ("line", new[] { 0f, -13.6f, 0f, -16f }) );
				break;

			default:                                                // a zigzag rising to a point
				s.Add( ("line", new[] { -1.9f, -15f, 1.9f, -10f, -1.9f, -5f, 1.9f, 0f, -1.9f, 5f, 1.9f, 10f, 0f, 14.5f }) );
				s.Add( ("dot", new[] { -3.2f, -12.5f, 0.55f }) );
				s.Add( ("dot", new[] { 3.2f, -2.5f, 0.55f }) );
				s.Add( ("dot", new[] { -3.2f, 7.5f, 0.55f }) );
				break;
		}

		return s.ToArray();
	}

	/// <summary>
	/// The engraving on its top: a hexagon inset 2u from its edge, turned as the pillar is, and a small ring in its middle
	/// under where the flame burns. In the top's own frame (`Rotation.LookAt( Up, Forward )`): u runs to world -Y, v to +X.
	/// </summary>
	static (string Kind, float[] P)[] AltarTopShapes()
	{
		var inset = (AltarApothem - 2f) / AltarApothem;
		var pts = new List<float>();
		foreach ( var p in AltarCorners().Append( AltarCorners()[0] ) )
		{
			pts.Add( -p.y * inset );
			pts.Add( p.x * inset );
		}

		return new (string Kind, float[] P)[]
		{
			("line", pts.ToArray()),
			("ring", new[] { 0f, 0f, 2.2f }),
		};
	}

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

	/// <summary>
	/// `nz_hex_altar [place|off]` — HOST: the Altar as it stands. `place` sets the cursed flame on it by hand, whoever holds
	/// it and whatever the lock — the fire must be out (`nz_hex_skipto torch`); `off` takes it off, back over tile 1, as
	/// `nz_hex_torch drop` does. By the rules, its carrier sets it there with E, the shield lock open.
	/// </summary>
	[ConCmd( "nz_hex_altar" )]
	public static void AltarCmd( 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 "place":
				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();
				Log.Info( "[nz-hex] 🗿 the cursed flame set on the altar by hand" );
				break;

			case "off":
				m.ResetFlame();
				break;

			default:
				Log.Warning( "[nz-hex] nz_hex_altar place or off — or nothing, to see where it stands" );
				return;
		}

		Log.Info( $"[nz-hex] {m.TorchStateText()} · the shield lock is {( m._lockOpen ? "open" : "shut" )}"
			+ ( OnBasalt ? $" · the altar {( m._altarGo.IsValid() ? "stands" : "is NOT built" )} at {AltarAt}" : " · the altar is basalt's" ) );
	}

	/// <summary>
	/// `nz_hex_altar_place` — HOST: take away any wall buy standing where the altar is — the ASP that marked the spot — so
	/// the two do not stand in one another. In memory, like every edit: Save (nz_save) keeps it.
	/// </summary>
	[ConCmd( "nz_hex_altar_place" )]
	public static void AltarPlaceCmd()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var cfg = ActiveConfig.Current;
		if ( cfg?.WallBuys is null ) { Log.Warning( "[nz-hex] no config here" ); return; }

		var gone = cfg.WallBuys.Where( b => b.Position.Distance( AltarAt ) < 24f ).ToList();
		foreach ( var b in gone ) cfg.WallBuys.Remove( b );
		if ( gone.Count > 0 ) WallBuyManager.Ensure()?.Rebuild();

		Log.Info( gone.Count == 0 ? "[nz-hex] no wall buy stands where the altar is"
			: $"[nz-hex] {string.Join( ", ", gone.Select( b => b.WeaponPrefab ) )} went from where the altar is — in memory: Save (nz_save) keeps it" );
	}

	/// <summary>
	/// `nz_hex_altar_flame [radius] [height] [flames] [scale] [light]` — the cursed flame as it burns on the altar: the radius
	/// of its sphere, how high its middle floats over the top, how many flames and how big, and how bright its light —
	/// redrawn at once; bare, it prints them. On this machine and until a restart, like `nz_hex_flame`.
	/// </summary>
	[ConCmd( "nz_hex_altar_flame" )]
	public static void AltarFlameCmd( float radius = 0f, float height = -1f, int flames = 0, float scale = 0f, float light = -1f )
	{
		if ( radius > 0f ) AltarFlameRadius = radius;
		if ( height >= 0f ) AltarFlameHeight = height;
		if ( flames > 0 ) AltarFlames = flames;
		if ( scale > 0f ) AltarFlameScale = scale;
		if ( light >= 0f ) AltarFlameLight = light;

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

		Log.Info( $"[nz-hex] the cursed flame on the altar: {AltarFlames} flames round a sphere of {AltarFlameRadius:0.#}u radius,"
			+ $" its middle {AltarFlameHeight:0.#}u over the altar's top, each flame {AltarFlameScale:0.##}× a burning body's,"
			+ $" its light {AltarFlameLight:0.##}" + ( m.IsValid() && m._flamePlacedShown ? "" : " — not on the altar now (nz_hex_altar place)" ) );
	}
}