EasterEgg/HexPlatforms.Lock.cs

Part of the HexPlatforms manager. Implements a four-glyph shield lockpad: deals a random 4-glyph code, accepts tries from players, opens the shield on correct code, jams on wrong code until next round, and draws the local keypad/lock visuals.

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

namespace NZombies;

/// <summary>
/// BASALT'S SHIELD LOCK. The cyan Combine shield — a hexagonal column of `EFFECTS/COM_SHIELD002A` beside teleporter #0's
/// near pad, 120u across and floor to ceiling — carries a lockpad on its east face, where the user had put a Desert Eagle
/// wall buy to mark the spot. The right code makes the lockpad and the shield disappear. Asked for as *"i want to replace
/// the wallbuy with a lockpad that needs a code of 4 numbers"*, then *"the right code makes both the lockpad and the
/// combine cyan wall disappear"*.
///
/// ⛔ THE CODE IS FOUR GLYPHS, NOT NUMBERS — four of the eight cryptic glyphs round the Color Rings clock
/// (`RingsClue.GlyphShapes`), one in each of the lockpad's four slots, and each slot is tinted in one of the four colours:
/// blue, yellow, green, red, in the colours' own order. Asked for as *"i dont want it to be numbers, i want it to be the 8
/// cryptic icons from the circle — and i want each slot in the lockpad to be slightly colored in the 4 colors we use"*.
/// Dealt at random, repeats allowed, as the platforms' glyphs are. It travels as four digits 1-8, glyph k as "k".
///
/// ⛔ THE CODE IS WRITTEN ON THE WALLS, WHERE ONLY THE CURSED FLAME SHOWS IT (`HexPlatforms.Code.cs`): each glyph a symbol
/// in its slot's colour on one of the 28 spots the user marked, a new code and new spots every game.
///
/// ⛔ ONE WRONG CODE JAMS IT UNTIL THE NEXT ROUND, FOR EVERYONE. Asked for as *"if i get the code wrong one time i cannot
/// input another code until the following round"*. ⚠️ THE WHOLE LOCK, NOT THE ONE WHO TYPED IT — a choice: a jam for the
/// typer alone would let a squad of four try four codes a round. The next round's start frees it
/// (<see cref="UnjamLock"/>).
///
/// ⛔ THE SHIELD IS PART OF A BIGGER MESH, SO IT IS SPLIT OUT OF IT — the first time the lock opens, as its twin's is when
/// the light blue flame brings that one down: both in `HexPlatforms.Shields.cs` (<see cref="SplitColumn"/>). The twin
/// column at the teleporter's far end ("Mesh 49") is not this lock's (`HexPlatforms.Twin.cs`).
///
/// ⛔ ENTERED ON A KEYPAD. Looking at the lock within reach shows "Press E - Enter the code" (`UsePrompt.ForShieldLock`), and
/// E opens a pad on screen (`KeypadMenu`): the eight glyphs as keys, the four tinted slots filling as they are pressed, the
/// code sent as the fourth goes in.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the CODE is HOST state and never leaves it whole — a client holding it could read it off its console. It leaves one
///   symbol at a time, as the cursed flame shows each (`HexPlatforms.Code.cs`). A new one every game, and `nz_hex_lock`
///   shows it, for testing;
/// - a TRY goes from the presser's machine to the host (`NZNet.ShieldLockTry`), which knows who from the call itself;
/// - OPEN and JAMMED are HOST state, MIRRORED to everyone (`NZNet.ShieldLockState`) and to a joiner. A wrong try is told
///   back (`NZNet.ShieldLockWrong`), so the presser's keypad can say so;
/// - the LOCKPAD and the SHIELD'S FACES are LOCAL: every machine draws its own lock and hides its own copy of the faces.
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ where it is ══════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// The lockpad's middle: where the Desert Eagle wall buy stood, 1.5u off the shield's east flat (x 1852), facing +X —
	/// read off it with `nz_wallbuy_list` on 2026-09-26.
	/// </summary>
	static Vector3 LockAt => new( 1853.5f, 614.45f, 1249.6f );

	/// <summary>Which way the lockpad faces: out of the shield's east flat.</summary>
	static Vector3 LockFacing => new( 1f, 0f, 0f );

	/// <summary>The shield column's middle (BSP brush 1467): its faces lie 60u out of it, the frame round its top twice that.</summary>
	static Vector3 ShieldCentre => new( 1792f, 608f, 1296.5f );

	/// <summary>How far a player's feet may be from the lockpad to use it, and how near a look must pass to its middle.</summary>
	static float? _lockReach;
	public static float LockReach { get => Math.Clamp( _lockReach ?? 110f, 40f, 400f ); set => _lockReach = value; }
	const float LockAimRadius = 16f;

	/// <summary>How far one player is from the lockpad. For `KeypadMenu`, which shuts when they walk away.</summary>
	public static float LockDistance( NZPlayer p ) => p.IsValid() ? LockAt.Distance( p.WorldPosition ) : float.MaxValue;

	// ══ the state ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// HOST — the code: four glyphs, one per slot in the colours' order (blue, yellow, green, red), as the digits 1-8; null
	/// until the first game deals it.
	/// </summary>
	string _lockGlyphs;

	/// <summary>HOST — is the lock open: the right code in, the lockpad and the shield gone?</summary>
	bool _lockOpen;

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

	/// <summary>HOST — is the lock jammed: a wrong code in this round, and no other taken until the next one starts?</summary>
	bool _lockJammed;

	/// <summary>MIRROR — the same, as this machine was told (`NZNet.ShieldLockState`). The prompt and the keypad say so.</summary>
	bool _lockJammedShown;

	/// <summary>HOST — whether it is open, and jammed, for `NZNet.PushState` to replay to a joiner.</summary>
	public static bool LockOpenState => Instance.IsValid() && Instance._lockOpen;
	public static bool LockJammedState => Instance.IsValid() && Instance._lockJammed;

	/// <summary>MIRROR — is it open on this machine? The keypad asks.</summary>
	public static bool LockOpenShown => Instance.IsValid() && Instance._lockOpenShown;

	/// <summary>MIRROR — is it jammed on this machine? The prompt and the keypad ask.</summary>
	public static bool LockJammedShown => Instance.IsValid() && Instance._lockJammedShown;

	/// <summary>The code as four glyph digits 1-8, dealt on first need. HOST.</summary>
	string LockCode => _lockGlyphs ??= DealLockCode();

	/// <summary>Four glyphs at random, 1-8 each, repeats allowed — as the platforms' glyphs are dealt.</summary>
	static string DealLockCode() => string.Concat( Enumerable.Range( 0, 4 ).Select( _ => (char)('1' + Game.Random.Next( 8 )) ) );

	/// <summary>A code in words, slot by slot: "blue 3 · yellow 7 · green 1 · red 8".</summary>
	static string Spell( string code )
		=> string.Join( " · ", Enumerable.Range( 0, Math.Min( 4, code?.Length ?? 0 ) ).Select( k => $"{ColourName( k )} {code[k]}" ) );

	/// <summary>Is this four glyph digits, 1-8?</summary>
	static bool IsGlyphCode( string code ) => code is { Length: 4 } && code.All( c => c >= '1' && c <= '8' );

	void SendLock() => NZNet.ShieldLockState( _lockOpen, _lockJammed );

	/// <summary>
	/// Open or shut, and jammed or not. EVERY machine — `NZNet.ShieldLockState`: the lockpad and the shield dressed to match,
	/// and the keypad told.
	/// </summary>
	public void ApplyLock( bool open, bool jammed )
	{
		var wasJammed = _lockJammedShown;
		_lockOpenShown = open;
		_lockJammedShown = jammed;
		BuildLock();

		if ( open ) KeypadMenu.Close();
		else if ( jammed && !wasJammed ) KeypadMenu.Jammed();
		else if ( wasJammed && !jammed ) KeypadMenu.Clear();
	}

	/// <summary>A new game: a new code on new spots, the lock shut and free, and the shield back. HOST — `RoundBegan( 1 )`.</summary>
	void ResetLock()
	{
		DealCode();
		_lockOpen = false;
		_lockJammed = false;
		SendLock();
	}

	/// <summary>A round began: a lock jammed by a wrong code takes codes again. HOST — `RoundBegan`, every round.</summary>
	void UnjamLock()
	{
		if ( !_lockJammed ) return;

		_lockJammed = false;
		SendLock();
		Log.Info( "[nz-hex] 🔒 a new round — the shield lock takes a code again" );
	}

	// ══ trying a code ════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// Is this player looking at the lockpad, shut and near enough to use? LOCAL — the prompt's test and the use key's, one
	/// question for both (`UsePrompt.ForShieldLock`, `NZPlayer.TickUse`): the camera's ray through a sphere round the pad,
	/// as for the cursed flame, since the pad has no collider. ⚠️ STILL TRUE WHILE IT IS JAMMED: the prompt then says so,
	/// and E does nothing — nor anything behind it.
	/// </summary>
	public static bool LockAimed( NZPlayer player )
	{
		var m = Instance;
		if ( !m.IsValid() || !OnBasalt || m._lockOpenShown || CannotCarry( player ) ) return false;
		if ( LockAt.Distance( player.WorldPosition ) > LockReach ) return false;

		var cam = m.Scene.Camera;
		return cam.IsValid() && RayMeetsSphere( cam.WorldPosition, cam.WorldRotation.Forward, LockAt, LockAimRadius );
	}

	/// <summary>
	/// A code typed on the keypad. HOST — `NZNet.ShieldLockTry`, with `who` the caller's connection id as the call itself
	/// carries it, or "" for the host's own. A wrong one is told back to whoever typed it.
	/// </summary>
	public static void HostTryCode( string who, string digits )
	{
		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;

		// ⛔ EVERY TRY IS ANSWERED (the co-op audit, 2026-09-27 — *"fix the keypad checking bug"*). Only a wrong code was: one
		// the host refused — from too far, or while down — left the typist's pad on "CHECKING…" for good.
		var tried = m.TryCode( body, digits );
		if ( tried == LockTry.Wrong ) NZNet.ShieldLockWrong( who ?? "" );
		else if ( tried == LockTry.Ignored ) NZNet.ShieldLockIgnored( who ?? "", m.WhyIgnored( body, digits ) );
	}

	/// <summary>Why a code was turned away, for the typist's pad — `TryCode`'s own tests, in its order.</summary>
	string WhyIgnored( NZPlayer body, string digits )
		=> _lockOpen ? "IT IS OPEN"
			: _lockJammed ? "JAMMED UNTIL THE NEXT ROUND"
			: CannotCarry( body ) ? "NOT WHILE DOWN"
			: LockAt.Distance( body.WorldPosition ) > LockAcceptReach ? "TOO FAR — STEP CLOSER"
			: !IsGlyphCode( digits ) ? "TRY AGAIN"
			: "TRY AGAIN";

	/// <summary>How far from the lock the host still takes a code: its reach, and the slack every reach allows.</summary>
	public static float LockAcceptReach => LockReach + ReachSlack;

	/// <summary>What a try did.</summary>
	enum LockTry { Opened, Wrong, Ignored }

	/// <summary>
	/// The rules for a code. HOST — apart from the RPC, so the selftest can walk it. The lock must be shut and not jammed,
	/// the presser up and within reach (`anywhere` skips the reach, for the test and `nz_hex_lock try`), and the code four
	/// glyphs. Right opens it, with the step-done clicking; wrong plays the power-down at the lock and jams it until the
	/// next round, for everyone.
	/// </summary>
	LockTry TryCode( NZPlayer body, string digits, bool anywhere = false )
	{
		if ( _lockOpen || _lockJammed || CannotCarry( body ) ) return LockTry.Ignored;
		if ( !anywhere && LockAt.Distance( body.WorldPosition ) > LockReach + ReachSlack ) return LockTry.Ignored;
		if ( !IsGlyphCode( digits ) ) return LockTry.Ignored;

		if ( digits != LockCode )
		{
			_lockJammed = true;
			SendLock();
			Cue( FailCue, LockAt );
			Log.Info( $"[nz-hex] 🔒 {NameFor( body )} tried {Spell( digits )} on the shield lock — wrong. It takes no other code"
				+ " until the next round" );
			return LockTry.Wrong;
		}

		_lockOpen = true;
		SendLock();
		Cue( DoneCue );
		Fanfare( 5 );
		Log.Info( $"[nz-hex] 🔓 THE SHIELD LOCK OPENS — {NameFor( body )} entered the code, and the lockpad and the cyan shield are gone" );
		return LockTry.Opened;
	}

	// ══ the lockpad and the shield ═══════════════════════════════════════════════════════════

	/// <summary>LOCAL — the lockpad drawn on the shield.</summary>
	GameObject _lockGo;

	/// <summary>
	/// How the lockpad is drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 2: glyph keys and four tinted slots (2026-09-26). A manager
	/// that has not laid this layout draws the lock again — `OnUpdate` asks every frame — so a running game shows the new
	/// pad at once, not at the next change.
	/// </summary>
	const int LockLayout = 2;

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


	// the lockpad's layout, in its own units: -0.8..0.8 across, -0.68..1 up

	/// <summary>Where slot k sits across the pad, and each slot's half-size.</summary>
	static float SlotX( int k ) => -0.57f + k * 0.38f;
	const float SlotY = 0.72f, SlotHalfW = 0.15f, SlotHalfH = 0.17f;

	/// <summary>Where glyph k's key sits: 1-4 across the upper row, 5-8 the lower, under the slots.</summary>
	static Vector2 KeyAt( int glyph ) => new( SlotX( (glyph - 1) % 4 ), glyph <= 4 ? 0.08f : -0.36f );
	const float KeyHalf = 0.17f, KeyGlyphUnit = 0.16f;

	/// <summary>World units to one of the lockpad's units: 24, so it is 38u wide and 40u tall. Its stroke, in its units.</summary>
	const float LockpadScale = 24f, LockpadStroke = 0.028f;

	/// <summary>A rectangle outline, as one stroke-family line, round its middle.</summary>
	static (string Kind, float[] P) Box( float x, float y, float halfW, float halfH )
		=> ("line", new[] { x - halfW, y - halfH, x + halfW, y - halfH, x + halfW, y + halfH, x - halfW, y + halfH, x - halfW, y - halfH });

	/// <summary>
	/// The lockpad's white light, in the stroke family: its frame, a rule under the slots, and the eight glyph keys — each a
	/// square with its glyph in it, drawn from `RingsClue.GlyphShapes` so it is the clock's own. A property, so a change
	/// reaches a running game (INSTRUCTIONS.md §1).
	/// </summary>
	static (string Kind, float[] P)[] LockpadShapes
	{
		get
		{
			var s = new List<(string Kind, float[] P)>
			{
				Box( 0f, 0.16f, 0.80f, 0.84f ),
				("line", new[] { -0.72f, 0.45f, 0.72f, 0.45f }),
			};

			for ( var g = 1; g <= RingPositions; g++ )
			{
				var at = KeyAt( g );
				s.Add( Box( at.x, at.y, KeyHalf, KeyHalf ) );
				foreach ( var (kind, p) in RingsClue.GlyphShapes( g ) )
				{
					var q = p.ToArray();                                  // ⚠️ NOT Array.Clone(): the sandbox's whitelist refuses it
					if ( kind == "line" )
						for ( var i = 0; i + 1 < q.Length; i += 2 ) { q[i] = at.x + q[i] * KeyGlyphUnit; q[i + 1] = at.y + q[i + 1] * KeyGlyphUnit; }
					else
					{
						q[0] = at.x + q[0] * KeyGlyphUnit;
						q[1] = at.y + q[1] * KeyGlyphUnit;
						q[2] *= KeyGlyphUnit;
					}
					s.Add( (kind, q) );
				}
			}

			return s.ToArray();
		}
	}

	/// <summary>
	/// The lockpad as this machine was told: the pad on the shield while shut — its white light and its four slots, each in
	/// its colour's own light, the tiles' — and both gone once open. LOCAL.
	/// </summary>
	void BuildLock()
	{
		if ( !OnBasalt ) { ClearLock(); return; }

		var open = _lockOpenShown;
		SetShieldHidden( open );

		if ( open || _lockLaid != LockLayout )
		{
			if ( _lockGo.IsValid() ) _lockGo.Destroy();
			_lockGo = null;
		}

		_lockLaid = LockLayout;
		if ( open || _lockGo.IsValid() ) return;

		var root = Scene.CreateObject();
		root.Name = "Basalt shield lock";
		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 = LockAt;
		root.WorldRotation = Rotation.LookAt( LockFacing, Vector3.Up );

		LockPart( root, "light", IconModel( LockpadShapes, LockpadStroke, White, LockpadScale ) );
		for ( var k = 0; k < Colours; k++ )
			LockPart( root, $"slot {k + 1} ({ColourName( k )})",
				IconModel( new[] { Box( SlotX( k ), SlotY, SlotHalfW, SlotHalfH ) }, LockpadStroke, k, LockpadScale ) );

		_lockGo = root;
	}

	/// <summary>One of the lockpad's parts: a child of it, in its plane, drawing this model.</summary>
	void LockPart( GameObject root, string name, Model model )
	{
		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 = Vector3.Zero;
		go.LocalRotation = Rotation.Identity;

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

	/// <summary>The lock's shield gone, or back — its column's, by the two columns' one way (`HexPlatforms.Shields.cs`). LOCAL.</summary>
	void SetShieldHidden( bool hidden ) => SetColumnHidden( LockColumn, hidden );

	/// <summary>Is the lock's shield there on this machine? For the selftest and `nz_hex_lock`.</summary>
	bool ShieldStands() => ColumnStands( LockColumn );

	void ClearLock()
	{
		if ( _lockGo.IsValid() ) _lockGo.Destroy();
		_lockGo = null;
		_lockLaid = LockLayout;
		SetShieldHidden( false );
	}


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

	/// <summary>
	/// `nz_hex_lock [open|close|jam|unjam|new|code &lt;gggg&gt;|try &lt;gggg&gt;]` — HOST: the shield lock, and its code. `open`
	/// opens it as the right code would, `close` shuts it with the shield back; `jam` jams it as a wrong code would, and
	/// `unjam` frees it as the next round does; `new` deals a new code on new spots, `code` sets the glyphs, and `try` enters
	/// one as you would on the keypad, from wherever you stand, by the rules — a jammed lock ignores it. A code is four
	/// glyphs, 1-8, blue's first. Bare, it says where it stands, and the code and where its symbols are, which is no secret
	/// here: this is the host's console, and testing.
	/// </summary>
	[ConCmd( "nz_hex_lock" )]
	public static void LockCmd( string what = "", string arg = "" )
	{
		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 "open":
				m._lockOpen = true;
				m.SendLock();
				break;

			case "close":
				m._lockOpen = false;
				m.SendLock();
				break;

			case "jam":
				m._lockJammed = true;
				m.SendLock();
				break;

			case "unjam":
				m._lockJammed = false;
				m.SendLock();
				break;

			case "new":
				m.DealCode();
				break;

			case "code":
				if ( !IsGlyphCode( arg.Trim() ) ) { Log.Warning( "[nz-hex] nz_hex_lock code <four glyphs, 1-8 each, blue's first> — e.g. 3718" ); return; }
				m._lockGlyphs = arg.Trim();
				break;

			case "try":
			{
				var got = m.TryCode( NZPlayer.Local, arg.Trim(), anywhere: true );
				Log.Info( $"[nz-hex] try {arg}: {got}" );
				break;
			}

			default:
				Log.Warning( "[nz-hex] nz_hex_lock open, close, jam, unjam, new, code <gggg> or try <gggg> — glyphs 1-8 — or nothing, to see where it stands" );
				return;
		}

		Log.Info( $"[nz-hex] the shield lock is {( m._lockOpen ? "OPEN — the lockpad and the shield gone" : m._lockJammed ? "shut, and JAMMED until the next round" : "shut" )}"
			+ $" · its code {Spell( m.LockCode )} · the shield {( m.ShieldStands() ? "stands" : "is down, or not found" )} here" );
		Log.Info( $"[nz-hex] its symbols: {m.SymbolsText()}" );
	}

	/// <summary>
	/// `nz_hex_lock_place` — HOST: take away any wall buy standing where the lockpad is — the Desert Eagle that marked the
	/// spot — so the two do not sit one on the other. In memory, like every edit: Save (nz_save) keeps it.
	/// </summary>
	[ConCmd( "nz_hex_lock_place" )]
	public static void LockPlaceCmd()
	{
		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( LockAt ) < 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 shield lock is"
			: $"[nz-hex] {string.Join( ", ", gone.Select( b => b.WeaponPrefab ) )} went from where the shield lock is — in memory: Save (nz_save) keeps it" );
	}
}