Mastermind puzzle logic and rendering for the HexPlatforms Easter egg. Handles hit detection for three ring panels and a ceiling lamp, wheel colours, host-side game state (secret, colours, tries, flags), network RPC calls, local visual construction of panel GameObjects and flashing/round logic, plus developer console commands for testing and tuning colours.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — THE MASTERMIND (named by the user, 2026-09-26). It begins when the Shrieker platform is done. Three hexagonal
/// columns stand in a triangle on the hex floor, each ringed at waist height by a band of the map's white light, with a
/// hexagonal light in the ceiling over them. The step deals a secret, one of eight colours for each ring, and gives each
/// ring a random colour of its own. A Prisma round on a ring moves it one colour on, round the same wheel for all three; a
/// Prisma round on the ceiling light tries the three as they stand, and each ring flashes three times: green if its colour
/// is the secret's, red if not. Three wrong tries a round, and the rings go white until the next round, which gives them
/// colours again. Right, and every light turns green for good, the ceiling's too, and the step-done clicking plays — and
/// the lava rises, the next step (`HexPlatforms.Lava.cs`).
/// Asked for as *"each of the ring lights become one of 8 colors randomly, shooting one of the rings changes it's light,
/// and it cicles in the same order for the 3 rings — once i want to try a solution, i shoot the exagonal light at the top,
/// and each of the 3 rings tells me if it's correct or wrong — i have 3 tries per round, once i fail 3 times i must wait
/// for the following round"*.
///
/// ⛔ THE LIGHTS ARE MAP GEOMETRY, as the tiles are (`HexSidePanels`). A ring's colour is our own panels laid just proud of
/// its six faces, in a tinted copy of the map's white001; with none showing, the map's own white is what shows. A shot is
/// found where the bullet lands (<see cref="OnBulletImpact"/>, from the hitscan bullet): by where, not by what.
///
/// ⚠️ ONLY THE PRISMA (`PrismaFx.IsFor`). The design has the wonder weapon required for this seal (Docs/BASALT_EASTER_EGG.md,
/// seal 4), and lights any gun could turn would make the whole weapon quest optional.
///
/// ⚠️ THE SECRET STAYS FOR THE GAME: a new round gives the rings new colours after a lock, never a new secret, so what three
/// tries taught is kept. A new game deals a new one.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - a SHOT is noticed on the shooter's machine, weapons being never networked, which asks the host (`NZNet.MastermindShot`);
/// - the SECRET is the HOST's alone, never sent;
/// - the RINGS' COLOURS, the tries and whether it is begun, locked or done are HOST state, MIRRORED (`NZNet.MastermindState`)
/// on every change and to a joiner;
/// - a TRY's answer is an EVENT (`NZNet.MastermindResult`): every machine flashes its own rings from it;
/// - the PANELS are LOCAL.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ where they are ══════════════════════════════════════════════════════════════════════
//
// Read off the BSP and traced in the game on 2026-09-26, where the user marked them with four 1911 wall buys. Each column
// runs from the hex floor at 1184 to the ceiling at 1918, a tile's size (flats 124u out). Its ring of white001 is set 16u
// into it, flats 108u and points 128u out, and shows from 1215 to 1230. The ceiling light is a hexagon of white001 at
// 1918, flats 56.45u and points 66.35u out, over the middle of the three.
/// <summary>How many rings, and the target number a round on the ceiling light is.</summary>
const int MmRings = 3, MmLamp = 3;
/// <summary>Each ring's column's middle, in the order the user marked them: west, south, east.</summary>
static Vector2 MmRingMiddle( int k ) => k switch { 0 => new( -340f, -400f ), 1 => new( -20f, -960f ), _ => new( 300f, -400f ) };
/// <summary>A ring's name, for the log: where its column stands.</summary>
static string MmRingName( int k ) => k switch { 0 => "west", 1 => "south", _ => "east" };
/// <summary>Where a ring shows: from the lip under it to the column over it.</summary>
const float MmRingBottom = 1215f, MmRingTop = 1230f;
/// <summary>
/// A ring's corners round its column's middle, counter-clockwise from the north point as `HexSidePanels.Corners` runs. A
/// property, not a static table: an initialiser does not re-run on hotload.
/// </summary>
static Vector2[] MmRingCorners => new Vector2[]
{
new( 0, 128 ), new( -108, 64 ), new( -108, -64 ), new( 0, -128 ), new( 108, -64 ), new( 108, 64 ),
};
/// <summary>The ceiling light's middle, at its face.</summary>
static Vector3 MmLampMiddle => new( -117.55f, -607.35f, 1918f );
/// <summary>The ceiling light's corners round its middle, counter-clockwise from the north point.</summary>
static Vector2[] MmLampCorners => new Vector2[]
{
new( 0, 66.35f ), new( -56.45f, 33f ), new( -56.45f, -33f ), new( 0, -66.35f ), new( 56.45f, -33f ), new( 56.45f, 33f ),
};
/// <summary>How far off a light's face a bullet may land and still be on it: floating point, and the recess's edges.</summary>
const float MmHitSlack = 2.5f;
/// <summary>How far below the ceiling light's face its green lies: half a unit, as a tile's panels stand off its sides.</summary>
const float MmLampProud = 0.5f;
/// <summary>
/// How far outside a convex polygon a point is, flat: the most it lies past any of its sides, negative inside. The
/// corners counter-clockwise, as the panels are built from.
/// </summary>
static float OutOfCorners( Vector3 p, Vector2 middle, Vector2[] corners )
{
var worst = float.MinValue;
for ( var i = 0; i < corners.Length; i++ )
{
var a = middle + corners[i];
var d = middle + corners[(i + 1) % corners.Length] - a;
var along = d * (1f / d.Length);
var outward = new Vector2( along.y, -along.x ); // the edge turned right: out of a counter-clockwise polygon
worst = MathF.Max( worst, (p.x - a.x) * outward.x + (p.y - a.y) * outward.y );
}
return worst;
}
/// <summary>What a bullet landing here hit: 0-2 a ring (west, south, east), <see cref="MmLamp"/> the ceiling light, -1 neither.</summary>
public static int MastermindTargetAt( Vector3 at )
{
if ( at.z >= MmRingBottom - MmHitSlack && at.z <= MmRingTop + MmHitSlack )
for ( var k = 0; k < MmRings; k++ )
if ( MathF.Abs( OutOfCorners( at, MmRingMiddle( k ), MmRingCorners ) ) <= MmHitSlack ) return k;
var lamp = MmLampMiddle;
if ( MathF.Abs( at.z - lamp.z ) <= MmHitSlack && OutOfCorners( at, new Vector2( lamp.x, lamp.y ), MmLampCorners ) <= MmHitSlack )
return MmLamp;
return -1;
}
// ══ the colours ═════════════════════════════════════════════════════════════════════════
/// <summary>How many colours a ring goes round.</summary>
const int WheelColours = 8;
/// <summary>
/// The eight, in the order a Prisma round moves a ring through them — the spectrum, closing on itself: red, orange, yellow,
/// green, light blue, blue, purple, pink, and red again. Asked for as *"the 4 we already use + orange, purple, pink and
/// light blue"*, cycling *"in the same order for the 3 rings"*.
/// </summary>
static string WheelName( int w ) => w switch
{
0 => "red", 1 => "orange", 2 => "yellow", 3 => "green", 4 => "light blue", 5 => "blue", 6 => "purple", _ => "pink",
};
/// <summary>A wheel colour from its name, or -1.</summary>
static int WheelOf( string name ) => name?.Trim().ToLowerInvariant().Replace( "_", " " ) switch
{
"red" => 0, "orange" => 1, "yellow" => 2, "green" => 3, "light blue" or "lightblue" => 4, "blue" => 5, "purple" => 6,
"pink" => 7, _ => -1,
};
/// <summary>The wheel's red and green — a wrong ring's flash and a right one's — and its light blue, the teleporter charged.</summary>
const int WheelRed = 0, WheelGreen = 3, WheelLightBlue = 4;
/// <summary>A wheel colour's Color Smash colour for the four the tiles use, so `nz_hex_colour` retunes both; else -1.</summary>
static int SmashColourOf( int w ) => w switch { 0 => 3, 2 => 1, 3 => 2, 5 => 0, _ => -1 };
/// <summary>
/// The four new colours' hues and drives, as the code has them: saturated, for the reason `Authored` gives — driven hard,
/// a pale hue arrives as white. First looks: retune by eye with `nz_hex_mastermind_colour`, then paste here. A method, not
/// a table, for the hotload.
/// </summary>
static (string Hex, float Drive) WheelAuthored( int w ) => w switch
{
1 => ("#FF4000", 2.2f), // orange
4 => ("#0099FF", 2.4f), // light blue
6 => ("#7000FF", 2.6f), // purple
_ => ("#FF0080", 2.2f), // pink
};
/// <summary>`nz_hex_mastermind_colour`'s retunes, by wheel colour — only what was changed.</summary>
static Dictionary<int, (Color Hue, float Drive)> _wheelTuned;
static (Color Hue, float Drive) WheelTuning( int w )
{
if ( _wheelTuned is not null && _wheelTuned.TryGetValue( w, out var t ) ) return t;
var (hex, drive) = WheelAuthored( w );
return (Color.Parse( hex ) ?? Color.White, drive);
}
static Dictionary<int, Material> _wheelCopies;
/// <summary>
/// The material a ring lit in this wheel colour is drawn with: Color Smash's own copy for the four the tiles use, and for
/// the other four a copy of white001 of their own, tinted — the tint written on every call, as `MaterialFor` does, so a
/// retune takes. Null if white001 is missing.
/// </summary>
static Material WheelMaterial( int w )
{
var smash = SmashColourOf( w );
if ( smash >= 0 ) return MaterialFor( smash );
var white = Material.Load( LightMaterial );
if ( white is null ) { Log.Warning( $"[nz-hex] {LightMaterial} not found" ); return null; }
_wheelCopies ??= new();
if ( !_wheelCopies.TryGetValue( w, out var m ) || m is null )
_wheelCopies[w] = m = white.CreateCopy( $"nz_hex_{WheelName( w ).Replace( ' ', '_' )}" );
var (hue, drive) = WheelTuning( w );
m.Set( "g_vColorTint", new Vector3( hue.r * drive, hue.g * drive, hue.b * drive ) );
return m;
}
// ══ the state ════════════════════════════════════════════════════════════════════════════
/// <summary>How many tries a round gives.</summary>
const int MastermindTries = 3;
/// <summary>
/// A try's answer: three flashes, each on then off; then a moment white, so the outcome the host sends after it
/// arrives before the rings show anything again.
/// </summary>
const float MmFlashOn = 0.25f, MmFlashOff = 0.25f, MmFlashSettle = 0.2f;
const int MmFlashCount = 3;
static float MmFlashSeconds => (MmFlashOn + MmFlashOff) * MmFlashCount;
/// <summary>How soon after a ring moved another round on it may move it again, in seconds: the Prisma fires ten a second.</summary>
const float MmShotCooldown = 0.3f;
/// <summary>The flags: begun; locked — the round's tries spent, the rings white until the next; done.</summary>
const int MmActive = 1, MmLocked = 2, MmDone = 4;
/// <summary>HOST — the rings' colours (three bits each, wheel colours), the secret the same way, the tries spent this round, the flags.</summary>
int _mmColours, _mmAnswer, _mmTries, _mmFlags;
/// <summary>MIRROR — the colours, the tries and the flags as this machine was told (`NZNet.MastermindState`). Never the secret.</summary>
int _mmColoursShown, _mmTriesShown, _mmFlagsShown;
/// <summary>HOST — a try's answer waiting out its flash, as bits (ring k right: bit k); -1 for none.</summary>
int _mmPending = -1;
TimeSince _mmPendingSince;
/// <summary>
/// HOST — when each ring last moved, for <see cref="MmShotCooldown"/>. ⚠️ MADE ON FIRST USE, NOT BY AN INITIALISER, so a
/// manager a hotload carried over from before the field existed still gets one.
/// </summary>
TimeSince[] _mmShot;
/// <summary>HOST — the Mastermind as it stands, for `NZNet.PushState` to replay to a joiner. Never the secret.</summary>
public static (int Colours, int Tries, int Flags) MastermindNow
=> Instance.IsValid() ? (Instance._mmColours, Instance._mmTries, Instance._mmFlags) : (0, 0, 0);
/// <summary>Is the Mastermind solved, as this machine was told? What the next step asks.</summary>
public bool MastermindDoneShown => (_mmFlagsShown & (MmActive | MmDone)) == (MmActive | MmDone);
static int MmColourIn( int packed, int k ) => (packed >> (3 * k)) & 7;
static int MmWithColour( int packed, int k, int w ) => (packed & ~(7 << (3 * k))) | ((w & 7) << (3 * k));
/// <summary>Three wheel colours, one per ring, each drawn on its own.</summary>
static int MmRandomColours()
{
var packed = 0;
for ( var k = 0; k < MmRings; k++ ) packed = MmWithColour( packed, k, Game.Random.Next( WheelColours ) );
return packed;
}
void SendMastermind() => NZNet.MastermindState( _mmColours, _mmTries, _mmFlags );
/// <summary>The Mastermind as it stands. EVERY machine — `NZNet.MastermindState`: the rings and the ceiling light dressed to match.</summary>
public void ApplyMastermind( int colours, int tries, int flags )
{
_mmColoursShown = colours;
_mmTriesShown = tries;
_mmFlagsShown = flags;
DressMastermind();
}
/// <summary>A try's answer, ring k right in bit k. EVERY machine — `NZNet.MastermindResult`: the rings flash it.</summary>
public void ApplyMastermindResult( int right )
{
_mmFlashRight = right;
_mmFlashSince = 0;
_mmFlashing = true;
DressMastermind();
}
/// <summary>The Mastermind begins: a secret dealt, each ring a colour of its own, three tries. HOST — the Shrieker platform done.</summary>
void StartMastermind()
{
if ( (_mmFlags & MmActive) != 0 ) return;
_mmAnswer = MmRandomColours();
_mmColours = MmRandomColours();
_mmTries = 0;
_mmFlags = MmActive;
_mmPending = -1;
SendMastermind();
Log.Info( $"[nz-hex] ◈ THE MASTERMIND BEGINS — the rings {MmColoursText( _mmColours )}. A Prisma round on a ring moves it one"
+ $" colour on, one on the ceiling light tries the three: {MastermindTries} tries a round" );
}
/// <summary>Back to not begun, the rings the map's white. HOST — with the Shrieker platform, which it follows.</summary>
void ResetMastermind()
{
// the rising lava follows the Mastermind, and goes with it
ResetLava();
_mmPending = -1;
if ( _mmFlags == 0 && _mmColours == 0 && _mmAnswer == 0 && _mmTries == 0 ) return;
_mmColours = _mmAnswer = _mmTries = _mmFlags = 0;
SendMastermind();
}
/// <summary>A round began: three tries again, and rings a lock left white take colours again. HOST — `RoundBegan`.</summary>
void MastermindRound()
{
if ( (_mmFlags & MmActive) == 0 || (_mmFlags & MmDone) != 0 ) return;
// a try still flashing is settled in the round it was made in
if ( _mmPending >= 0 ) ResolveMastermind();
if ( (_mmFlags & MmDone) != 0 ) return;
var locked = (_mmFlags & MmLocked) != 0;
if ( !locked && _mmTries == 0 ) return;
_mmTries = 0;
if ( locked )
{
_mmFlags &= ~MmLocked;
_mmColours = MmRandomColours();
Log.Info( $"[nz-hex] ◈ the Mastermind's rings take colours again: {MmColoursText( _mmColours )}. {MastermindTries} tries" );
}
SendMastermind();
}
// ══ the shots ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// A bullet landed on the world here. ON THE SHOOTER'S MACHINE — the hitscan bullet, at every impact. A Prisma round on a
/// ring or the ceiling light, while the Mastermind takes rounds as this machine was told, asks the host.
///
/// ⚠️ EVERY IMPACT OF EVERY GUN COMES THROUGH HERE, so the cheapest questions go first: a field, then the map, the gun.
/// </summary>
public static void OnBulletImpact( SWB.Base.Weapon weapon, Vector3 at )
{
var m = Instance;
if ( !m.IsValid() ) return;
// ⚠️ AND THE JUNCTIONS' BOXES, THE STEP AFTER THE LAVA (`HexPlatforms.Junctions.cs`): the same round, the same rule
var mastermind = (m._mmFlagsShown & (MmActive | MmLocked | MmDone)) == MmActive;
var junctions = (m._jnFlagsShown & (JnActive | JnTesting | JnDone)) == JnActive;
if ( !mastermind && !junctions ) return;
if ( !OnBasalt || !PrismaFx.IsFor( weapon ) ) return;
if ( mastermind )
{
var target = MastermindTargetAt( at );
if ( target >= 0 ) { NZNet.MastermindShot( target ); return; }
}
if ( junctions )
{
var junction = JunctionAt( at );
if ( junction >= 0 ) NZNet.JunctionShot( junction );
}
}
/// <summary>
/// A Prisma round landed on a ring or the ceiling light. HOST — `NZNet.MastermindShot`, with `who` the shooter's connection
/// id as the call carries it, or "" for the host's own round.
/// </summary>
public static void HostMastermindShot( string who, int target )
{
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;
// ⚠️ SAID FOR THE CEILING LIGHT ONLY, AND NOT WHILE A TRY FLASHES: the Prisma fires ten a second, and a held trigger
// would fill the console with "a try is still flashing" for the whole flash
var flashing = m._mmPending >= 0;
var refused = m.MastermindShot( target, by: NameFor( body ) );
if ( refused != "" && target == MmLamp && !flashing ) Log.Info( $"[nz-hex] the Mastermind takes no try now: {refused}" );
}
/// <summary>
/// The rules for a round on one of its lights. HOST — apart from the RPC, so the selftest can walk it. A ring moves one
/// colour on, unless it moved a moment ago (`anytime` skips that, for the test and the command); the ceiling light tries
/// the three as they stand. Nothing while it is not begun, is locked, is done or a try is flashing. Returns why not, or "".
/// </summary>
string MastermindShot( int target, bool anytime = false, string by = "" )
{
if ( (_mmFlags & MmActive) == 0 ) return "it has not begun";
if ( (_mmFlags & MmDone) != 0 ) return "it is solved";
if ( (_mmFlags & MmLocked) != 0 ) return "its tries are spent until the next round";
if ( _mmPending >= 0 ) return "a try is still flashing";
if ( target == MmLamp )
{
TryMastermind( by );
return "";
}
if ( target < 0 || target >= MmRings ) return "no such ring";
_mmShot ??= new TimeSince[MmRings];
if ( !anytime && _mmShot[target] < MmShotCooldown ) return "it moved a moment ago";
_mmShot[target] = 0;
_mmColours = MmWithColour( _mmColours, target, (MmColourIn( _mmColours, target ) + 1) % WheelColours );
SendMastermind();
return "";
}
/// <summary>The three as they stand, tried: each ring's answer flashed on every machine, the outcome once it has. HOST.</summary>
void TryMastermind( string by )
{
var right = 0;
for ( var k = 0; k < MmRings; k++ )
if ( MmColourIn( _mmColours, k ) == MmColourIn( _mmAnswer, k ) ) right |= 1 << k;
_mmPending = right;
_mmPendingSince = 0;
NZNet.MastermindResult( right );
Log.Info( $"[nz-hex] ◈ the Mastermind tried{( string.IsNullOrEmpty( by ) ? "" : $" by {by}" )}: {MmColoursText( _mmColours )}"
+ $" — {MmRightText( right )}" );
}
/// <summary>A try's flash has run its course. HOST — `OnUpdate`.</summary>
void WatchMastermind()
{
if ( _mmPending >= 0 && _mmPendingSince >= MmFlashSeconds ) ResolveMastermind();
}
/// <summary>
/// A try's outcome. All three right: solved — every light green, and the step-done clicking. Else a try is spent, and the
/// round's last locks it, the rings white until the next round. HOST.
/// </summary>
void ResolveMastermind()
{
var right = _mmPending;
_mmPending = -1;
if ( right < 0 ) return;
if ( right == (1 << MmRings) - 1 )
{
_mmFlags |= MmDone;
SendMastermind();
Cue( DoneCue );
Log.Info( "[nz-hex] ✦ THE MASTERMIND IS SOLVED — every light turns green, and the lava will rise" );
// the teleporter's charge begins: the beast roars, and the ground shakes — every screen, violently, easing out. ⚠️ THE
// QUAKE IS THE SECOND ROAR'S NOW (`EggFanfare`, 2026-09-28), three seconds after the clicking, not with it
Fanfare( 10 );
// …and the next step begins: the rising lava (`HexPlatforms.Lava.cs`)
StartLava();
return;
}
_mmTries++;
if ( _mmTries >= MastermindTries )
{
_mmFlags |= MmLocked;
Cue( FailCue, MmLampMiddle );
Log.Info( $"[nz-hex] ◈ the Mastermind's {MastermindTries} tries are spent — the rings go white until the next round" );
}
SendMastermind();
}
// ══ the lights ══════════════════════════════════════════════════════════════════════════
/// <summary>LOCAL — each ring's panels, off while it shows the map's white; and the ceiling light's green, once it is solved.</summary>
GameObject[] _mmRingGo;
GameObject _mmLampGo;
/// <summary>LOCAL — what each ring's panels show now: a wheel colour, -1 off, -2 not yet dressed. Made on first use.</summary>
int[] _mmRingShown;
/// <summary>LOCAL — a try's answer flashing: the rings it found right, and since when.</summary>
int _mmFlashRight;
TimeSince _mmFlashSince;
bool _mmFlashing;
/// <summary>
/// How the lights are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: the rings' panels and the ceiling light's, 2026-09-26; 2: a
/// ring's colour set while it is switched off, 2026-09-27. A manager that has not built this layout dresses them again —
/// `OnUpdate` asks every frame — as the altar's does.
/// </summary>
const int MastermindLayout = 2;
/// <summary>LOCAL — the layout this manager last dressed them in; 0 before it has.</summary>
int _mmLaid;
/// <summary>
/// What a ring shows now, on this machine: a wheel colour, or -1 for the map's white. Not begun, white; a try flashing,
/// its answer on and off three times, then a moment white; solved, green; locked, white; else the ring's own colour.
/// </summary>
int MmRingShows( int k )
{
if ( (_mmFlagsShown & MmActive) == 0 ) return -1;
if ( _mmFlashing )
{
var period = MmFlashOn + MmFlashOff;
var phase = (int)(_mmFlashSince / period);
if ( phase >= MmFlashCount ) return -1;
if ( _mmFlashSince - phase * period >= MmFlashOn ) return -1;
return (_mmFlashRight >> k & 1) != 0 ? WheelGreen : WheelRed;
}
// the junctions' route, blinked by all three once the lava is survived, over their green (`HexPlatforms.Junctions.cs`)
var blink = JunctionBlinkShown();
if ( blink != -2 ) return blink;
if ( (_mmFlagsShown & MmDone) != 0 ) return WheelGreen;
if ( (_mmFlagsShown & MmLocked) != 0 ) return -1;
return MmColourIn( _mmColoursShown, k );
}
/// <summary>
/// A try's flash, and the junctions' route blinked, run on every machine. LOCAL — `OnUpdate`, every frame while either
/// runs; only a ring whose colour changes is touched.
/// </summary>
void TickMastermindFlash()
{
if ( !_mmFlashing && !JunctionsBlinking ) return;
if ( _mmFlashing && _mmFlashSince >= MmFlashSeconds + MmFlashSettle ) _mmFlashing = false;
DressMastermind();
}
/// <summary>The rings and the ceiling light as this machine was told, on basalt; nothing anywhere else. LOCAL.</summary>
void DressMastermind()
{
_mmLaid = MastermindLayout;
if ( !OnBasalt || !Scene.IsValid() ) { ClearMastermind(); return; }
_mmRingGo ??= new GameObject[MmRings];
_mmRingShown ??= new[] { -2, -2, -2 };
for ( var k = 0; k < MmRings; k++ )
{
var shows = MmRingShows( k );
if ( shows == _mmRingShown[k] && (shows < 0 || _mmRingGo[k].IsValid()) ) continue;
_mmRingShown[k] = shows;
if ( shows < 0 )
{
if ( _mmRingGo[k].IsValid() ) _mmRingGo[k].Enabled = false;
continue;
}
if ( !_mmRingGo[k].IsValid() ) _mmRingGo[k] = BuildMmRing( k );
if ( !_mmRingGo[k].IsValid() ) continue;
// ⛔ EverythingInSelf — THE RING IS SWITCHED OFF HERE whenever it was white a moment ago, and the default lookup
// does not see a switched-off object's renderer: its colour stayed as it was, and the junctions' route blinked its
// first colour six times (*"the rings are flashing the same color 6 times"*, 2026-09-27) — and after a try's
// flash, or a locked round, a ring came back in the flash's colour, not its own
var r = _mmRingGo[k].Components.Get<ModelRenderer>( FindMode.EverythingInSelf );
if ( r.IsValid() ) r.MaterialOverride = WheelMaterial( shows );
_mmRingGo[k].Enabled = true;
}
// the ceiling light: green once it is solved and the last flash has run, for good
var lampGreen = MastermindDoneShown && !_mmFlashing;
if ( lampGreen && !_mmLampGo.IsValid() ) _mmLampGo = BuildMmLamp();
else if ( !lampGreen && _mmLampGo.IsValid() )
{
_mmLampGo.Destroy();
_mmLampGo = null;
}
}
/// <summary>Every panel of the Mastermind's gone, the lights the map's own white. LOCAL.</summary>
void ClearMastermind()
{
if ( _mmRingGo is not null )
for ( var k = 0; k < _mmRingGo.Length; k++ )
{
if ( _mmRingGo[k].IsValid() ) _mmRingGo[k].Destroy();
_mmRingGo[k] = null;
}
if ( _mmRingShown is not null )
for ( var k = 0; k < _mmRingShown.Length; k++ ) _mmRingShown[k] = -2;
if ( _mmLampGo.IsValid() ) _mmLampGo.Destroy();
_mmLampGo = null;
}
/// <summary>One ring's panels: light quads over its six faces, in white001 — the colour is its renderer's override.</summary>
GameObject BuildMmRing( int k )
{
var mid = MmRingMiddle( k );
var model = HexSidePanels.Build( new[] { new Tile( $"ring {k + 1}", mid.x, mid.y, MmRingTop, MmRingBottom ) }, null,
corners: MmRingCorners );
if ( model is null ) return null;
var go = Scene.CreateObject();
go.Name = $"Mastermind ring {k + 1} ({MmRingName( k )})";
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 );
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
return go;
}
/// <summary>
/// The ceiling light in green: a flat hexagon half a unit under its face, facing down. Each wedge is wound the other way
/// round from the corners, which is what faces it down — the panels' rule, the normal a counter-clockwise triangle gives.
/// </summary>
GameObject BuildMmLamp()
{
var material = WheelMaterial( WheelGreen );
if ( material is null ) return null;
var mid = MmLampMiddle;
var z = mid.z - MmLampProud;
var corners = MmLampCorners;
var verts = new List<Vertex>();
var idx = new List<int>();
verts.Add( new Vertex( new Vector3( mid.x, mid.y, z ), Vector3.Down, Vector3.Forward, new Vector4( 0.5f, 0.5f, 0f, 0f ) ) );
foreach ( var c in corners )
verts.Add( new Vertex( new Vector3( mid.x + c.x, mid.y + c.y, z ), Vector3.Down, Vector3.Forward,
new Vector4( 0.5f + c.x / 140f, 0.5f - c.y / 140f, 0f, 0f ) ) );
for ( var i = 0; i < corners.Length; i++ )
{
idx.Add( 0 );
idx.Add( 1 + (i + 1) % corners.Length );
idx.Add( 1 + i );
}
// ⛔ SET BY HAND, AFTER THE BUFFERS: a runtime mesh gets empty bounds otherwise (`HexSidePanels.Build`)
var bounds = new BBox( new Vector3( mid.x - 60f, mid.y - 70f, z - 1f ), new Vector3( mid.x + 60f, mid.y + 70f, z + 1f ) );
var mesh = new Mesh( material );
mesh.CreateVertexBuffer( verts.Count, verts );
mesh.CreateIndexBuffer( idx.Count, idx );
mesh.Bounds = bounds;
var model = Model.Builder.AddMesh( mesh ).WithViewBounds( bounds ).Create();
var go = Scene.CreateObject();
go.Name = "Mastermind ceiling light (green)";
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 );
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
return go;
}
// ══ in words ════════════════════════════════════════════════════════════════════════════
static string MmColoursText( int packed )
=> string.Join( " · ", Enumerable.Range( 0, MmRings ).Select( k => $"{MmRingName( k )} {WheelName( MmColourIn( packed, k ) )}" ) );
static string MmRightText( int right )
=> string.Join( ", ", Enumerable.Range( 0, MmRings ).Select( k => $"{MmRingName( k )} {( (right >> k & 1) != 0 ? "right" : "wrong" )}" ) );
/// <summary>Where it stands, in words. HOST.</summary>
string MastermindStateText()
{
if ( (_mmFlags & MmActive) == 0 ) return "the Mastermind: waiting for the Shrieker platform";
if ( (_mmFlags & MmDone) != 0 ) return "the Mastermind SOLVED — every light green";
var tries = (_mmFlags & MmLocked) != 0
? "its tries spent: the rings white until the next round"
: $"{MastermindTries - _mmTries} of {MastermindTries} tries left this round";
return $"the Mastermind: the rings {MmColoursText( _mmColours )} · {tries}";
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_mastermind [start|shoot n|try|answer|solve|done|lock|round|reset]` — HOST: where the Mastermind stands.
/// `start` begins it, as the Shrieker platform's end does; `shoot n` is a Prisma round on ring n (1 west, 2 south, 3 east)
/// and `try` one on the ceiling light; `answer` prints the secret — testing only, it gives it away; `solve` turns the
/// rings to it, for `try` to finish; `done` finishes it at once, and the lava's warning begins; `lock` spends the round's
/// tries; `round` is a new round's start for it; `reset` puts it back to not begun, and the lava with it.
/// </summary>
[ConCmd( "nz_hex_mastermind" )]
public static void MastermindCmd( string what = "", int n = 0 )
{
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; }
var begun = (m._mmFlags & MmActive) != 0;
var refused = "";
switch ( what.Trim().ToLowerInvariant() )
{
case "":
break;
case "start":
if ( m._shriekerKills < ShriekersTarget ) Log.Warning( "[nz-hex] the Shrieker platform is not done, which begins it — begun by hand" );
m.StartMastermind();
break;
case "shoot":
if ( n < 1 || n > MmRings ) { Log.Warning( "[nz-hex] nz_hex_mastermind shoot 1, 2 or 3 — west, south or east" ); return; }
refused = m.MastermindShot( n - 1, anytime: true );
break;
case "try":
refused = m.MastermindShot( MmLamp );
break;
case "answer":
Log.Info( begun
? $"[nz-hex] the Mastermind's secret: {MmColoursText( m._mmAnswer )} — testing only, it gives it away"
: "[nz-hex] the Mastermind has not begun, so it has no secret yet" );
return;
case "solve":
if ( !begun ) { Log.Warning( "[nz-hex] the Mastermind has not begun — nz_hex_mastermind start first" ); return; }
m._mmColours = m._mmAnswer;
m.SendMastermind();
break;
case "done":
m.StartMastermind();
m._mmPending = -1;
m._mmColours = m._mmAnswer;
m._mmFlags = MmActive | MmDone;
m.SendMastermind();
m.StartLava();
break;
case "lock":
if ( !begun ) { Log.Warning( "[nz-hex] the Mastermind has not begun — nz_hex_mastermind start first" ); return; }
m._mmPending = -1;
m._mmTries = MastermindTries;
m._mmFlags |= MmLocked;
m.SendMastermind();
break;
case "round":
m.MastermindRound();
break;
case "reset":
m.ResetMastermind();
break;
default:
Log.Warning( "[nz-hex] nz_hex_mastermind start, shoot 1-3, try, answer, solve, done, lock, round or reset — or nothing,"
+ " to see where it stands" );
return;
}
if ( refused != "" ) Log.Warning( $"[nz-hex] the Mastermind takes no round: {refused}" );
Log.Info( $"[nz-hex] {m.MastermindStateText()}" );
}
/// <summary>
/// `nz_hex_mastermind_colour [colour] [#hex] [drive]` — retune one of the four new colours by eye (orange, lightblue,
/// purple, pink), on this machine and until a restart; every ring lit in it changes at once. Bare, it prints all eight in
/// the wheel's order. The other four are Color Smash's: `nz_hex_colour` retunes them, the rings with the tiles.
/// </summary>
[ConCmd( "nz_hex_mastermind_colour" )]
public static void MastermindColourCmd( string colour = "", string hex = "", float drive = 0f )
{
if ( string.IsNullOrWhiteSpace( colour ) )
{
for ( var k = 0; k < WheelColours; k++ )
{
var smash = SmashColourOf( k );
var (h, d) = smash >= 0 ? Tuning( smash ) : WheelTuning( k );
Log.Info( $"[nz-hex] {k + 1}. {WheelName( k )}: {h.Hex} × {d:0.##}" + ( smash >= 0 ? " — Color Smash's, nz_hex_colour" : "" ) );
}
return;
}
var w = WheelOf( colour );
if ( w < 0 || SmashColourOf( w ) >= 0 )
{
Log.Warning( "[nz-hex] orange, lightblue, purple or pink — red, yellow, green and blue are Color Smash's: nz_hex_colour" );
return;
}
var (hue, dr) = WheelTuning( w );
if ( !string.IsNullOrWhiteSpace( hex ) )
{
if ( Color.Parse( hex ) is not Color c ) { Log.Warning( $"[nz-hex] '{hex}' is not a colour — #RRGGBB" ); return; }
hue = c;
}
if ( drive > 0f ) dr = drive;
_wheelTuned ??= new();
_wheelTuned[w] = (hue, dr);
WheelMaterial( w );
Log.Info( $"[nz-hex] {WheelName( w )}: {hue.Hex} × {dr:0.##} — in WheelAuthored: {w} => (\"{hue.Hex}\", {dr:0.##}f)," );
}
}