EasterEgg/HexPlatforms.Code.cs

A partial class HexPlatforms that manages the map locations and visual display of four puzzle glyph symbols for a shield lock. It defines the 28 fixed spot positions and facings, deals a random four-spot code, decides which symbols should show based on a carried cursed flame and reveal radius (host authority), packs shown-symbol state into a compact SymbolSet, and creates/destroys local GameObjects to draw symbols and previews.

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

namespace NZombies;

/// <summary>
/// BASALT — THE SHIELD LOCK'S CODE, WRITTEN ON THE WALLS. The code's four glyphs (`HexPlatforms.Lock.cs`) stand as four
/// symbols on four of the 28 spots the user marked round the map with Desert Eagle wall buys
/// (Docs/BASALT_MARKED_SPOTS.md), each in the colour of the lockpad slot it goes in, and only the cursed flame shows them.
/// Asked for as *"those 28 spots are where the 4 code symbols can appear — each symbol will have one of the 4 colors with
/// no repeats — and each of the 4 symbols will appear in one of the 28 slots with no repeats — the code changes every
/// game … these symbols are invisible to players, but if someone is carrying the cursed flame and is within a medium
/// radius of one of the symbols, it becomes visible"*, and *"the color of the symbol matches the slot it's supposed to go
/// in in the lockpad"*.
///
/// ⛔ THE RULES:
/// - four symbols, one per colour — blue, yellow, green, red, the slots' order — each the glyph its slot wants, drawn from
///   the Color Rings clock's own shapes (`RingsClue.GlyphShapes`) in its colour's light, the tiles';
/// - on four DIFFERENT spots of the 28, dealt with the code: a new game, or a config loaded, deals both again
///   (<see cref="DealCode"/>) — never a new round;
/// - a symbol shows while the cursed flame's carrier is within <see cref="RevealRadius"/> of it, to everyone, and goes
///   again when they walk away. ⚠️ AND ONLY FROM ITS OWN SIDE OF ITS SURFACE — a choice, not asked for: the flame lights
///   what it stands before, so a carrier on the far side of a wall does not light the symbol on its near face;
/// - once the lock is open, none shows: the code is spent.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the SPOTS, like the glyphs, are HOST state, dealt there and never sent whole;
/// - which symbols SHOW is decided by the HOST — from where it sees the carrier — five times a second, and MIRRORED
///   (`NZNet.CodeSymbols`) on every change and to a joiner. ⚠️ ONLY A SYMBOL ON SHOW CARRIES ITS SPOT AND GLYPH
///   (<see cref="SymbolSet"/>), so the code leaves the host one symbol at a time, as the flame shows it — never before;
/// - the SYMBOLS themselves are LOCAL: every machine draws its own from the set it was sent.
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ the spots ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// The 28 spots, n = 1-28 west to east as Docs/BASALT_MARKED_SPOTS.md numbers them (index n - 1 here): where each of the
	/// user's Desert Eagle wall buys stood, 1.5u off its surface, and its angles — pitch, yaw, roll — which face straight
	/// out of it, read with `nz_wallbuy_list` on 2026-09-26 before they were taken away. 24 are on walls, three on ceilings
	/// (5, 17, 19) and one on a floor (3); #7 is on Debris 10, which never moves. A property, so a change reaches a running
	/// game (INSTRUCTIONS.md §1).
	/// </summary>
	static (Vector3 At, Angles Facing)[] CodeSpots => new (Vector3, Angles)[]
	{
		(new( -5052.91f, -398.42f, 1843.82f ), new( 0f, -120.11f, 0f )),     //  1 wall
		(new( -4582.47f, -428.26f, 1759.15f ), new( 0f, 119.74f, 0f )),      //  2 wall
		(new( -4503.36f, -23.04f, 1377.50f ), new( -90f, -90f, 0f )),        //  3 floor
		(new( -4222.50f, -569.34f, 1758.63f ), new( 0f, 0f, 0f )),           //  4 wall
		(new( -3120.43f, -1275.15f, 1366.50f ), new( 90f, 90f, 0f )),        //  5 ceiling
		(new( -3090.50f, -2091.84f, 1204.58f ), new( 0f, 0f, 0f )),          //  6 wall
		(new( -2737.02f, -369.05f, 1212.61f ), new( 0f, 89.40f, 0f )),       //  7 wall, on Debris 10
		(new( -2420.03f, -2198.91f, 1419.69f ), new( 0f, -59.86f, 0f )),     //  8 wall
		(new( -1723.11f, -86.81f, 1217.15f ), new( 0f, -119.74f, 0f )),      //  9 wall
		(new( -1497.62f, -2204.81f, 1468.40f ), new( 0f, 59.86f, 0f )),      // 10 wall
		(new( -1477.50f, 497.29f, 1220.22f ), new( 0f, 180f, 0f )),          // 11 wall
		(new( -1098.50f, 383.55f, 1237.05f ), new( 0f, 0f, 0f )),            // 12 wall
		(new( -1054.50f, -1641.32f, 1466.53f ), new( 0f, 0f, 0f )),          // 13 wall
		(new( -970.50f, -290.38f, 1234.92f ), new( 0f, 0f, 0f )),            // 14 wall
		(new( -614.53f, -1077.31f, 1483.64f ), new( 0f, 59.86f, 0f )),       // 15 wall
		(new( -476.87f, -1973.08f, 1484.82f ), new( 0f, -59.86f, 0f )),      // 16 wall
		(new( -282.17f, 710.44f, 1353.50f ), new( 90f, 90f, 0f )),           // 17 ceiling
		(new( -158.50f, -2310.65f, 1361.53f ), new( 0f, 0f, 0f )),           // 18 wall
		(new( -18.75f, 521.26f, 1364.50f ), new( 90f, 90f, 0f )),            // 19 ceiling
		(new( 9.50f, -626.64f, 1303.96f ), new( 0f, 0f, 0f )),               // 20 wall
		(new( 105.50f, -967.83f, 1257.08f ), new( 0f, 0f, 0f )),             // 21 wall
		(new( 365.73f, -292.43f, 1254.76f ), new( 0f, 59.86f, 0f )),         // 22 wall
		(new( 619.66f, 636.93f, 1261.34f ), new( 0f, -60.26f, 0f )),         // 23 wall
		(new( 850.83f, 690.20f, 1353.25f ), new( 0f, 119.74f, 0f )),         // 24 wall
		(new( 1373.50f, 1088.20f, 1315.70f ), new( 0f, 0f, 0f )),            // 25 wall
		(new( 1776.55f, 901.10f, 1220.69f ), new( 0f, -119.74f, 0f )),       // 26 wall
		(new( 1804.70f, 320.47f, 1219.80f ), new( 0f, 60.25f, 0f )),         // 27 wall
		(new( 1829.89f, -99.57f, 1245.19f ), new( 0f, -179.49f, 0f )),       // 28 wall
	};

	/// <summary>What a spot is on, from which way it faces: a wall, a ceiling (facing down) or a floor (facing up).</summary>
	static string SpotKind( Angles facing ) => facing.pitch > 45f ? "ceiling" : facing.pitch < -45f ? "floor" : "wall";

	// ══ the deal ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// HOST — where each of the code's four symbols stands: for each colour, blue's first, an index into
	/// <see cref="CodeSpots"/>, four different ones. Null until dealt: read it through <see cref="SymbolSpots"/>, since a
	/// manager a hotload carried over can hold a code dealt before the spots were.
	/// </summary>
	int[] _codeSpots;

	/// <summary>The symbols' spots, dealt on first need. HOST.</summary>
	int[] SymbolSpots => _codeSpots is { Length: Colours } ? _codeSpots : (_codeSpots = DealSpots());

	/// <summary>Four different spots of the 28, at random, one per colour.</summary>
	static int[] DealSpots()
	{
		var pool = Enumerable.Range( 0, CodeSpots.Length ).ToList();
		var spots = new int[Colours];
		for ( var k = 0; k < Colours; k++ )
		{
			var i = Game.Random.Next( pool.Count );
			spots[k] = pool[i];
			pool.RemoveAt( i );
		}

		return spots;
	}

	/// <summary>
	/// A new code: four glyphs, and four different spots for them to stand on. HOST — a new game (<see cref="ResetLock"/>),
	/// a config loaded (`OnConfigShown`), `nz_hex_lock new`.
	///
	/// ⚠️ A COUNT, NOT A LIST, in the log, as the picks are: the console is readable by anyone at the machine, and
	/// `nz_hex_lock` shows the code on purpose when testing.
	/// </summary>
	void DealCode()
	{
		_lockGlyphs = DealLockCode();
		_codeSpots = DealSpots();
		Log.Info( $"[nz-hex] 🔒 a new code for the shield lock — {Colours} glyphs, on {Colours} of the {CodeSpots.Length} spots" );
	}

	/// <summary>The code's symbols in words, slot by slot: "blue 3 on spot 12 (wall) · …", those on show marked. HOST.</summary>
	string SymbolsText()
	{
		var spots = CodeSpots;
		var at = SymbolSpots;
		var code = LockCode;
		return string.Join( " · ", Enumerable.Range( 0, Colours ).Select( k =>
			$"{ColourName( k )} {code[k]} on spot {at[k] + 1} ({SpotKind( spots[at[k]].Facing )})"
			+ ( _symbolsSent.Shows( k ) ? " — SHOWING" : "" ) ) );
	}

	// ══ which show ══════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// The symbols on show, in one long: for colour k, blue's first, ten bits from bit 10k — 1 if it shows, its spot (five
	/// bits, 0-27) and its glyph less one (three bits). A symbol not on show is all zeros, so nothing of it travels.
	/// </summary>
	public readonly record struct SymbolSet( long Packed )
	{
		const int Stride = 10;

		/// <summary>Does this colour's symbol show?</summary>
		public bool Shows( int colour ) => colour >= 0 && colour < Colours && ((Packed >> (colour * Stride)) & 1L) != 0;

		/// <summary>The spot a colour's symbol shows on, an index into the 28 — 0 when it does not show.</summary>
		public int SpotOf( int colour ) => Shows( colour ) ? (int)((Packed >> (colour * Stride + 1)) & 31L) : 0;

		/// <summary>The glyph a colour's symbol is, 1-8 — 0 when it does not show.</summary>
		public int GlyphOf( int colour ) => Shows( colour ) ? (int)((Packed >> (colour * Stride + 6)) & 7L) + 1 : 0;

		/// <summary>This set with a colour's symbol showing, on this spot, as this glyph.</summary>
		public SymbolSet With( int colour, int spot, int glyph )
		{
			if ( colour < 0 || colour >= Colours ) return this;

			var bits = 1L | ((long)(spot & 31) << 1) | ((long)((glyph - 1) & 7) << 6);
			return new SymbolSet( (Packed & ~(1023L << (colour * Stride))) | (bits << (colour * Stride)) );
		}

		/// <summary>How many show.</summary>
		public int Count => Enumerable.Range( 0, Colours ).Count( Shows );
	}

	static float? _revealRadius, _symbolScale;

	/// <summary>
	/// How near the cursed flame must come to a symbol to show it, in units: 250 — "a medium radius", a few strides. Every
	/// spot has somewhere to stand well inside it: measured off the map's floors on 2026-09-26, a flame at the shoulder of a
	/// carrier standing by a spot is within some 120u of it, the ceilings' the furthest. HOST — the host decides what shows.
	/// </summary>
	public static float RevealRadius { get => Math.Clamp( _revealRadius ?? 250f, 32f, 2000f ); set => _revealRadius = value; }

	/// <summary>How big a symbol is drawn: units to one glyph unit, 16 — the widest glyph about 28u across. LOCAL.</summary>
	public static float SymbolScale { get => Math.Clamp( _symbolScale ?? 16f, 2f, 200f ); set => _symbolScale = value; }

	/// <summary>HOST — the symbols the host last said show: what `NZNet.CodeSymbols` last carried.</summary>
	SymbolSet _symbolsSent;

	/// <summary>HOST — `nz_hex_symbols all`: every symbol shown, wherever the flame is. Testing only — it gives the code away.</summary>
	bool _symbolsAll;

	/// <summary>HOST — what shows, for `NZNet.PushState` to replay to a joiner.</summary>
	public static SymbolSet SymbolsState => Instance.IsValid() ? Instance._symbolsSent : default;

	/// <summary>HOST — when the host last looked.</summary>
	TimeSince _symbolWatch;

	/// <summary>
	/// How often the host looks, in seconds: five times a second, so a symbol shows within a fifth of a second of the flame
	/// coming near — nobody walks far in that.
	/// </summary>
	const float SymbolWatchEvery = 0.2f;

	/// <summary>
	/// HOST — from `OnUpdate`, five times a second: which symbols the cursed flame shows now, and everyone told when that
	/// changes.
	/// </summary>
	void WatchSymbols()
	{
		if ( _symbolWatch < SymbolWatchEvery ) return;
		_symbolWatch = 0f;

		var now = SymbolsNow();
		if ( now == _symbolsSent ) return;

		_symbolsSent = now;
		NZNet.CodeSymbols( now.Packed );
	}

	/// <summary>What shows now: none off basalt or with the lock open, all four by `nz_hex_symbols all`, and otherwise those near the carried flame. HOST.</summary>
	SymbolSet SymbolsNow()
	{
		if ( !OnBasalt || _lockOpen ) return default;
		if ( _symbolsAll ) return AllSymbols();

		return CarriedFlameAt() is Vector3 flame ? SymbolsFrom( flame ) : default;
	}

	/// <summary>
	/// Where the cursed flame is as the host sees it: at its carrier's shoulder, where everyone but the carrier sees it held
	/// (<see cref="OtherHold"/>) — or null, nobody carrying it. HOST.
	/// </summary>
	Vector3? CarriedFlameAt()
	{
		if ( string.IsNullOrEmpty( _flameCarrier ) || _flameLost ) return null;

		var body = CarrierBodyOf( _flameCarrier );
		if ( !body.IsValid() ) return null;

		return body.WorldPosition + Rotation.FromYaw( body.WorldRotation.Yaw() ) * OtherHold;
	}

	/// <summary>
	/// The symbols a flame here shows: each within <see cref="RevealRadius"/> of it, on the side its surface faces. HOST —
	/// apart from the carrier, so the selftest can walk it.
	/// </summary>
	SymbolSet SymbolsFrom( Vector3 flame )
	{
		var spots = CodeSpots;
		var at = SymbolSpots;
		var code = LockCode;

		var s = default( SymbolSet );
		for ( var k = 0; k < Colours; k++ )
		{
			var (p, facing) = spots[at[k]];
			var to = flame - p;
			if ( to.Length <= RevealRadius && Vector3.Dot( to, facing.ToRotation().Forward ) > 0f )
				s = s.With( k, at[k], code[k] - '0' );
		}

		return s;
	}

	/// <summary>All four symbols showing. HOST — `nz_hex_symbols all`.</summary>
	SymbolSet AllSymbols()
	{
		var at = SymbolSpots;
		var code = LockCode;

		var s = default( SymbolSet );
		for ( var k = 0; k < Colours; k++ ) s = s.With( k, at[k], code[k] - '0' );
		return s;
	}

	// ══ the symbols on the walls ════════════════════════════════════════════════════════════

	/// <summary>MIRROR — the symbols on show, as this machine was told (`NZNet.CodeSymbols`). They are drawn from it.</summary>
	SymbolSet _symbolsShown;

	/// <summary>LOCAL — each colour's symbol as drawn, and the set they were drawn from.</summary>
	GameObject[] _symbolGos;
	SymbolSet _symbolsBuilt;

	/// <summary>
	/// How the symbols are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: the glyph in its colour's light, 2026-09-26. A manager
	/// that has not drawn this layout draws them again — `OnUpdate` asks every frame — as the lock's `LockLayout` does.
	/// </summary>
	const int SymbolsLayout = 1;

	/// <summary>LOCAL — the layout this manager last drew the symbols in; 0 before it has.</summary>
	int _symbolsLaid;

	/// <summary>LOCAL — `nz_hex_symbols spots`: a white glyph on every one of the 28 spots, to check where they are.</summary>
	bool _spotsPreview;
	List<GameObject> _spotPreviewGos;

	/// <summary>What a symbol's object is named, and a preview spot's: how a sweep knows them.</summary>
	const string SymbolName = "Basalt code symbol", SpotPreviewName = "Basalt code spot";

	/// <summary>Which symbols show. EVERY machine — `NZNet.CodeSymbols`: each drawn on its spot, or taken away.</summary>
	public void ApplySymbols( SymbolSet s )
	{
		_symbolsShown = s;
		BuildSymbols();
	}

	/// <summary>
	/// The symbols as this machine was told: each colour's on show drawn on its spot, facing out of its surface, the rest
	/// gone — and, for `nz_hex_symbols spots`, a white glyph on all 28. Each is drawn again only when what it shows
	/// changes. LOCAL, and only on basalt.
	/// </summary>
	void BuildSymbols()
	{
		_symbolGos ??= new GameObject[Colours];

		// a new layout: every symbol and preview drawn again, and any a manager before this one left, swept
		if ( _symbolsLaid != SymbolsLayout )
		{
			_symbolsLaid = SymbolsLayout;
			ClearSymbols();
			if ( Scene.IsValid() )
				foreach ( var old in Scene.GetAllObjects( false )
					.Where( x => x.Tags.Has( PanelTag ) && (x.Name.StartsWith( SymbolName ) || x.Name.StartsWith( SpotPreviewName )) )
					.ToList() )
					old.Destroy();
		}

		var s = OnBasalt ? _symbolsShown : default;
		for ( var k = 0; k < Colours; k++ )
		{
			var same = s.Shows( k ) == _symbolsBuilt.Shows( k ) && s.SpotOf( k ) == _symbolsBuilt.SpotOf( k )
				&& s.GlyphOf( k ) == _symbolsBuilt.GlyphOf( k );
			if ( same && _symbolGos[k].IsValid() == s.Shows( k ) ) continue;

			if ( _symbolGos[k].IsValid() ) _symbolGos[k].Destroy();
			_symbolGos[k] = s.Shows( k )
				? SymbolAt( s.SpotOf( k ), s.GlyphOf( k ), k, $"{SymbolName} ({ColourName( k )}, spot {s.SpotOf( k ) + 1})" )
				: null;
		}
		_symbolsBuilt = s;

		BuildSpotPreview();
	}

	/// <summary>A glyph drawn on a spot, facing out of its surface, in a colour's light — or white. Null if it cannot be made. LOCAL.</summary>
	GameObject SymbolAt( int spot, int glyph, int colour, string name )
	{
		var spots = CodeSpots;
		if ( !Scene.IsValid() || spot < 0 || spot >= spots.Length ) return null;

		var model = IconModel( RingsClue.GlyphShapes( glyph ), RingsClue.GlyphStroke, colour, SymbolScale );
		if ( model is null ) return null;

		var (at, facing) = spots[spot];
		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.Tags.Add( PanelTag );
		go.WorldPosition = at;
		go.WorldRotation = facing.ToRotation();              // its Forward the surface's normal: the right way round to whoever faces it

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

	/// <summary>`nz_hex_symbols spots`: all 28 spots, each with a white glyph — spot n shows glyph (n - 1) mod 8 + 1. LOCAL.</summary>
	void BuildSpotPreview()
	{
		var want = _spotsPreview && OnBasalt;
		if ( want == (_spotPreviewGos is { Count: > 0 }) ) return;

		if ( _spotPreviewGos is not null )
			foreach ( var go in _spotPreviewGos )
				if ( go.IsValid() ) go.Destroy();
		_spotPreviewGos = null;
		if ( !want ) return;

		_spotPreviewGos = new();
		for ( var i = 0; i < CodeSpots.Length; i++ )
		{
			var go = SymbolAt( i, i % RingPositions + 1, White, $"{SpotPreviewName} {i + 1} (preview)" );
			if ( go.IsValid() ) _spotPreviewGos.Add( go );
		}
	}

	void ClearSymbols()
	{
		if ( _symbolGos is not null )
			for ( var k = 0; k < _symbolGos.Length; k++ )
			{
				if ( _symbolGos[k].IsValid() ) _symbolGos[k].Destroy();
				_symbolGos[k] = null;
			}
		_symbolsBuilt = default;

		if ( _spotPreviewGos is not null )
			foreach ( var go in _spotPreviewGos )
				if ( go.IsValid() ) go.Destroy();
		_spotPreviewGos = null;
	}

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

	/// <summary>
	/// `nz_hex_symbols [all|auto|spots|nospots]` — the code's symbols. Bare, where each stands and whether it shows — the
	/// host's alone, since that is the code. `all` shows all four to everyone, wherever the flame is, and `auto` goes back to
	/// the flame's rule — both HOST, testing only, it gives the code away. `spots` draws a white glyph on every one of the
	/// 28 spots, on this machine, to check where each is and which way it faces; `nospots` takes them away.
	/// </summary>
	[ConCmd( "nz_hex_symbols" )]
	public static void SymbolsCmd( string what = "" )
	{
		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }

		var host = !NZGame.IsClient;
		switch ( what.Trim().ToLowerInvariant() )
		{
			case "":
				break;

			case "all":
			case "auto":
				if ( !host ) { Log.Warning( "[nz-hex] host only — the host decides what shows" ); return; }
				m._symbolsAll = what.Trim().ToLowerInvariant() == "all";
				m._symbolWatch = SymbolWatchEvery;
				m.WatchSymbols();
				break;

			case "spots":
			case "nospots":
				m._spotsPreview = what.Trim().ToLowerInvariant() == "spots";
				m.BuildSymbols();
				Log.Info( m._spotsPreview
					? $"[nz-hex] a white glyph on every one of the {CodeSpots.Length} spots, on this machine: spot n shows glyph (n - 1) mod 8 + 1 · nz_hex_symbols nospots takes them away"
					: "[nz-hex] the spots' white glyphs are gone" );
				return;

			default:
				Log.Warning( "[nz-hex] nz_hex_symbols all, auto, spots or nospots — or nothing, to see where they stand" );
				return;
		}

		if ( !host )
		{
			Log.Info( $"[nz-hex] {m._symbolsShown.Count} of {Colours} of the code's symbols show here now (only the host knows where the rest stand)" );
			return;
		}

		Log.Info( $"[nz-hex] the code's symbols: {m.SymbolsText()}" );
		Log.Info( $"[nz-hex] they show within {RevealRadius:0}u of the cursed flame"
			+ ( m._symbolsAll ? " — ALL FORCED ON (nz_hex_symbols auto undoes)" : "" )
			+ ( m._lockOpen ? " — none now: the lock is open" : "" )
			+ ( m.CarriedFlameAt() is null ? " · nobody carries it now" : "" ) );
	}

	/// <summary>
	/// `nz_hex_reveal [radius] [size]` — how near the cursed flame must come to show a symbol, in units, and how big the
	/// symbols are drawn (units to a glyph unit) — redrawn at once; bare, it prints them. Until a restart, like
	/// `nz_hex_flame`: the radius counts on the host, which decides what shows, and the size on each machine.
	/// </summary>
	[ConCmd( "nz_hex_reveal" )]
	public static void RevealCmd( float radius = 0f, float size = 0f )
	{
		if ( radius > 0f ) RevealRadius = radius;
		if ( size > 0f ) SymbolScale = size;

		var m = Instance;
		if ( m.IsValid() && size > 0f ) { m._symbolsLaid = 0; m.BuildSymbols(); }

		Log.Info( $"[nz-hex] a symbol shows within {RevealRadius:0}u of the cursed flame (it counts on the host), drawn"
			+ $" {SymbolScale:0.#}u to a glyph unit — the widest glyph about {SymbolScale * 1.75f:0}u across" );
	}
}