Manager component for the map's hexagon slots used in an Easter egg puzzle. It handles creating and placing slot objects, generating and packing a randomized roll of numbers (I-IV) and colours, enforcing the rule for successive-round rolls, mirroring and broadcasting shown state from host to clients, drawing slot geometry and numerals, and many editor/console commands for testing and editing slots.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT SEAL 1 — THE HEX SLOTS (Docs/BASALT_EASTER_EGG.md). Each is a hexagon of light on a wall showing a number, I to
/// IV, in one of the four colours, and no number and no colour comes up twice. Asked for as *"these slots will be
/// randomized every game to be a number from 1-4 and one of the 4 colors, no number and color must repeat"*.
///
/// ⛔ THEY ROLL AGAIN AT EVERY ROUND'S END, AND EACH ROLL FOLLOWS THE LAST (<see cref="Next"/>): every slot's number
/// changes, every slot's colour changes, and every colour's number changes. Asked for as *"every round the numbers and
/// colors change for each slot, meaning if red IV was on slot 2, on the next round red cannot be IV and cannot be on slot
/// 2"*, and *"each number should be able to be any color"*. The first version rolled once a game, so for a whole game
/// each number kept one colour.
///
/// ⛔ THEY ARE THE STEP'S ORDER. The colour on the slot showing I is slammed first, then II's, III's and IV's
/// (`HexPlatforms`, <see cref="ColoursInOrder"/>). Once all four are slammed in order the step is done, and the slots
/// go — asked for as *"the numbers also disappear after that"* — until a new game.
///
/// ⛔ THEY APPEAR ONLY ONCE THE POWER IS ON, go again when a new game turns it off, and go for good once the step is done. Asked for as *"make it so they only
/// appear after the power has been turned on"*. Before that there is nothing of them on the walls, not even a bare hexagon.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the SPOTS are config (`MapConfig.HexSlots`), which every machine is sent whole;
/// - the ROLL is HOST state until the power is on. From then the walls show it to everyone, so the host MIRRORS it as what
/// the slots show (<see cref="Shown"/>, `NZNet.HexSlotsShown`): nothing before the power, the roll after. The clue's
/// picks work the same way. A client that held the roll early could read the order off its console before anyone could
/// see it;
/// - the LIGHTS are LOCAL: every machine builds its own from the spots and what it was sent.
///
/// ⚠️ THE HOST DECIDES WHEN, NOT EACH MACHINE'S OWN `Power.IsOn`. The power reaches every machine when it is switched, but
/// nothing replays it to a player who joins later, so a joiner's own flag can read off while the host's reads on. What the
/// slots show IS replayed to a joiner (`NZNet.PushState`), so they see what everyone else sees.
///
/// ⚠️ THE SAME SHAPE AS EVERY PLACEABLE MANAGER: `Ensure` creates on demand, NotSaved keeps it out of the scene, and
/// `Rebuild` is the one way anything is built. It is called from the same three places as the rest: `NZGame.ShowConfig`,
/// `RoundManager.StartGame` (as <see cref="NewGame"/>, which rolls first) and the map editor's tool. `RoundManager`'s round
/// clear rolls them again (<see cref="OnRoundEnd"/>).
///
/// ⚠️ A SLOT IS LIGHT, NOT A PANEL: flat strips of the map's own light material on the wall, in the colour's tinted copy
/// (<see cref="HexPlatforms.MaterialFor"/>). That is the material a lit tile of that colour is drawn with, so a slot and
/// its tiles are exactly one colour, and `nz_hex_colour` retunes both.
/// </summary>
public sealed class HexSlotManager : Component
{
public static HexSlotManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
const string SlotTag = "nz_hexslot";
/// <summary>The scene's manager, made on first use.</summary>
public static HexSlotManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
// ⚠️ ORPHANED SLOTS ARE SWEPT FIRST, for `HexPlatforms.Ensure`'s reason: a hotload destroys a component made at
// runtime, and a new manager would otherwise find the old one's slots still on the walls, tracked by nothing — and
// build a second set over them.
foreach ( var old in scene.GetAllObjects( false ).Where( g => g.Tags.Has( SlotTag ) ).ToList() )
old.Destroy();
var go = scene.CreateObject();
go.Name = "Hex Slot Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<HexSlotManager>();
}
// ══ the roll ════════════════════════════════════════════════════════════════════════════
/// <summary>How many slots can have a number and a colour: four — the numbers 1-4, one slot per colour.</summary>
public const int Max = HexPlatforms.Colours;
/// <summary>
/// A game's roll: each slot's number, 1-4, and colour (<see cref="HexPlatforms.ColourName"/>), in one int so it crosses
/// the network as one message. Slot i is bits 4i to 4i+3 — its number less one, then its colour — and bits 16-18 are how
/// many slots were rolled. `default` is none.
/// </summary>
public readonly record struct Roll( int Packed )
{
/// <summary>How many slots this roll covers: the placed ones, up to <see cref="Max"/>.</summary>
public int Count => (Packed >> 16) & 7;
/// <summary>A slot's number, 1-4, or 0 for a slot the roll does not cover.</summary>
public int NumberOf( int slot ) => slot < 0 || slot >= Count ? 0 : ((Packed >> (slot * 4)) & 3) + 1;
/// <summary>A slot's colour, 0-3, or -1 for a slot the roll does not cover.</summary>
public int ColourOf( int slot ) => slot < 0 || slot >= Count ? -1 : (Packed >> (slot * 4 + 2)) & 3;
/// <summary>The roll that gives slot i the number numbers[i] (1-4) and the colour colours[i].</summary>
public static Roll Of( IReadOnlyList<int> numbers, IReadOnlyList<int> colours )
{
var n = Math.Min( Math.Min( numbers.Count, colours.Count ), Max );
var p = n << 16;
for ( var i = 0; i < n; i++ )
p |= (((numbers[i] - 1) & 3) | ((colours[i] & 3) << 2)) << (i * 4);
return new Roll( p );
}
}
/// <summary>
/// A fresh roll for this many slots: the numbers 1-4 shuffled, the colours shuffled on their own, and slot i given the
/// i-th of each. So no number and no colour comes up twice, and which number goes with which colour is as random as
/// either.
/// </summary>
public static Roll Make( int slots )
{
var n = Math.Clamp( slots, 0, Max );
var numbers = Shuffled( Max ).Select( k => k + 1 ).Take( n ).ToList();
var colours = Shuffled( HexPlatforms.Colours ).Take( n ).ToList();
return Roll.Of( numbers, colours );
}
/// <summary>0 … count−1 in a random order — Fisher-Yates, off `Game.Random`.</summary>
static List<int> Shuffled( int count )
{
var l = Enumerable.Range( 0, count ).ToList();
for ( var i = l.Count - 1; i > 0; i-- )
{
var j = Game.Random.Next( i + 1 );
(l[i], l[j]) = (l[j], l[i]);
}
return l;
}
/// <summary>
/// The roll after <paramref name="prev"/>: every slot's number changes, every slot's colour changes, and every colour's
/// number changes. So if slot 2 showed red IV, next round slot 2 shows another number in another colour, and red is on
/// another slot with another number. One of all the rolls that keep that, at random; for four slots there are always 24.
/// With nothing to follow — the first roll, or slots placed or removed since — any roll will do (<see cref="Make"/>).
///
/// ⚠️ EVERY ROLL IS TRIED, NOT A SHUFFLE REDRAWN UNTIL ONE FITS: 576 rolls of four is nothing, and picking from all the
/// ones that fit is fair to each of them and cannot run long.
/// </summary>
public static Roll Next( Roll prev, int slots )
{
var n = Math.Clamp( slots, 0, Max );
if ( n == 0 || prev.Count != n ) return Make( n );
var orders = Orders( Max );
var fits = new List<Roll>();
foreach ( var ns in orders )
foreach ( var cs in orders )
{
var r = Roll.Of( ns.Take( n ).Select( k => k + 1 ).ToList(), cs.Take( n ).ToList() );
if ( Follows( prev, r ) ) fits.Add( r );
}
return fits.Count > 0 ? fits[Game.Random.Next( fits.Count )] : Make( n );
}
/// <summary>
/// Does <paramref name="next"/> follow <paramref name="prev"/>: every slot's number changed, every slot's colour changed,
/// and every colour's number changed?
/// </summary>
public static bool Follows( Roll prev, Roll next )
{
if ( next.Count != prev.Count ) return false;
for ( var i = 0; i < prev.Count; i++ )
{
if ( next.NumberOf( i ) == prev.NumberOf( i ) || next.ColourOf( i ) == prev.ColourOf( i ) ) return false;
// the colour slot i showed, wherever it is now, must not carry the number it had
for ( var j = 0; j < next.Count; j++ )
if ( next.ColourOf( j ) == prev.ColourOf( i ) && next.NumberOf( j ) == prev.NumberOf( i ) ) return false;
}
return true;
}
/// <summary>A number, 1-4, as Roman numerals — what a slot shows.</summary>
public static string Numeral( int number ) => number switch
{
1 => "I", 2 => "II", 3 => "III", 4 => "IV", _ => "",
};
/// <summary>A slot in words, for the log: "II red", or "not rolled".</summary>
static string Label( int number, int colour )
=> number > 0 ? $"{Numeral( number )} {HexPlatforms.ColourName( colour )}" : "not rolled";
// ══ state ═══════════════════════════════════════════════════════════════════════════════
/// <summary>HOST — this game's roll. It leaves the host only as <see cref="_shown"/>, once the power is on.</summary>
Roll _roll;
/// <summary>
/// MIRROR — what the slots show: this game's roll once the power is on, nothing before it. The host sends it
/// (<see cref="SendShown"/>); every machine builds its slots from it.
/// </summary>
Roll _shown;
/// <summary>What the slots show, for `NZNet.PushState` to replay to a joiner.</summary>
public Roll Shown => _shown;
/// <summary>This game's roll, shown or not. HOST — for `nz_hex_selftest` to check the order against.</summary>
public Roll HostRoll => _roll;
/// <summary>Are they on the walls by hand (`nz_hex_slots_show 1`), whatever the power and the step say?</summary>
public bool ShownByHand => _forced;
/// <summary>Why the slots are off the walls, in words.</summary>
static string Hidden() => HexPlatforms.StepDone ? "gone — Color Smash is done" : "hidden until the power is on";
/// <summary>
/// The order the slots give: for each number, I to IV, the colour on the slot showing it. HOST — `HexPlatforms` slams
/// by it. Null unless this game's roll puts all four numbers on the walls.
/// </summary>
public static int[] ColoursInOrder()
{
var m = Instance;
if ( !m.IsValid() || m._roll.Count < Max ) return null;
var order = new int[Max];
for ( var i = 0; i < Max; i++ ) order[m._roll.NumberOf( i ) - 1] = m._roll.ColourOf( i );
return order;
}
/// <summary>HOST — `nz_hex_slots_show 1`: the slots on the walls with the power off, to look at or place. Testing only.</summary>
bool _forced;
/// <summary>
/// HOST — the power as last seen, so <see cref="OnUpdate"/> notices it come on, or go off at a new game.
///
/// ⚠️ NULLABLE, SO THE FIRST FRAME ALWAYS SENDS, whatever the power is. A new manager, or one a hotload has just handed
/// this field, may find slots on the walls from before, and has to settle what they show.
/// </summary>
bool? _poweredWas;
/// <summary>LOCAL — the slots this machine built, one per placed spot, in the config's order.</summary>
readonly List<GameObject> _built = new();
/// <summary>How many are standing right now.</summary>
public int Built => _built.Count( g => g.IsValid() );
/// <summary>How many the config places.</summary>
static int Placed => ActiveConfig.Current?.HexSlots?.Count ?? 0;
/// <summary>What the slots show now, for the tool panel: "I blue · IV red …", or why nothing.</summary>
public static string Showing()
{
var n = Placed;
if ( n == 0 ) return "none placed";
var m = Instance;
var r = m.IsValid() ? m._shown : default;
if ( r.Count == 0 ) return $"{Hidden()} · nz_hex_slots_show 1 shows them";
return string.Join( " · ", Enumerable.Range( 0, Math.Min( n, Max ) ).Select( i => Label( r.NumberOf( i ), r.ColourOf( i ) ) ) )
+ ( m._forced && !Power.IsOn ? " · shown by hand, the power is off" : "" );
}
// ══ rolling and building ════════════════════════════════════════════════════════════════
/// <summary>
/// A new game: a new roll. HOST — `RoundManager.StartGame`, which has turned the power off by then. It is this manager's
/// rebuild there too: what the slots show (nothing, until the power comes on) goes to every machine, and each builds
/// from it.
/// </summary>
public void NewGame()
{
if ( NZGame.IsClient ) return;
// no slots on this map: nothing to roll, and nothing to tell anyone
if ( Placed == 0 && _roll.Count == 0 ) { Build(); return; }
RollNow( "a new game" );
}
/// <summary>
/// A round was cleared: the next round's roll, following this one's (<see cref="Next"/>). HOST — `RoundManager`'s round
/// clear, beside the step's own reset (`HexPlatforms.OnRoundEnd`), so the slots and the tiles change together.
/// </summary>
public static void OnRoundEnd( int round, string why = null )
{
// ⚠️ NOT ONCE THE STEP IS DONE: the slots are gone for good, and the order they gave has been used
if ( NZGame.IsClient || Placed == 0 || HexPlatforms.StepDone ) return;
var m = Ensure();
if ( m.IsValid() ) m.RollNow( why ?? $"round {round} over" );
}
/// <summary>Roll, and tell everybody what the slots show now. HOST.</summary>
void RollNow( string why )
{
_roll = Next( _roll, Placed );
SendShown();
if ( _roll.Count == 0 ) return;
// ⚠️ A LIST ONLY ONCE THEY ARE ON THE WALLS. Before that the roll is the puzzle, and the console is readable by anyone
// at the machine; `nz_hex_slots` prints it on purpose, when testing. `HexPlatforms.Pick` logs a count for the same
// reason.
Log.Info( _shown.Count > 0
? $"[nz-hex] slots rolled ({why}) — {Describe( _roll, _roll.Count )}"
: $"[nz-hex] slots rolled ({why}) — {Hidden()}" );
}
/// <summary>What the slots show: nothing, or — once <paramref name="on"/> — this game's roll. HOST.</summary>
Roll ShownFor( bool on ) => on ? _roll : default;
/// <summary>
/// Tell everybody what the slots show now: the roll while the power is on and the step not done, or by hand. HOST — at
/// every roll, when the power comes on or goes off, and when the step is done or starts over.
/// </summary>
void SendShown() => NZNet.HexSlotsShown( ShownFor( _forced || (Power.IsOn && !HexPlatforms.StepDone) ).Packed );
/// <summary>The step was done, or started over: show what the slots should. HOST — `HexPlatforms.SetDone`.</summary>
public void RefreshShown()
{
if ( NZGame.IsClient ) return;
SendShown();
}
/// <summary>What the slots show. EVERY machine — `NZNet.HexSlotsShown`. Builds them from it.</summary>
public void ApplyShown( Roll r )
{
_shown = r;
Build();
}
/// <summary>
/// HOST: notice the power change, and put the slots on the walls — or take them off.
///
/// ⚠️ POLLED, NOT `Power.OnPowered`, for `HexPlatforms.OnUpdate`'s reasons: the power also goes OFF, at every new game
/// (`Power.Reset`), and nothing fires for that; and a static event would hold on to this component past a hotload.
/// </summary>
protected override void OnUpdate()
{
if ( NZGame.IsClient ) return;
var on = Power.IsOn;
if ( on == _poweredWas ) return;
_poweredWas = on;
// a map with no slots has nothing to show, and nothing to tell anyone
if ( Placed == 0 && _shown.Count == 0 ) return;
SendShown();
}
/// <summary>
/// Destroy what is standing and build the config's slots again, with this game's roll. On the host, a new roll first if
/// the slots placed no longer match it: none yet, or one placed or removed in the editor.
///
/// ⚠️ A ROLL THAT STILL FITS IS KEPT. This runs on every mode change and config load, and rolling there would change the
/// slots mid-game for nothing. Only a round's end (<see cref="OnRoundEnd"/>) and a new game (<see cref="NewGame"/>) roll
/// regardless.
/// </summary>
public void Rebuild()
{
if ( !NZGame.IsClient && _roll.Count != Math.Min( Placed, Max ) )
{
// what the slots show comes straight back to this machine, through ApplyShown, and that builds them
RollNow( _roll.Count == 0 ? "the first build" : "the slots placed changed" );
return;
}
Build();
}
void Build()
{
foreach ( var g in _built )
if ( g.IsValid() ) g.Destroy();
_built.Clear();
var list = ActiveConfig.Current?.HexSlots;
if ( list is null || list.Count == 0 ) return;
// ⛔ NOTHING ON THE WALLS UNTIL THE POWER IS ON, not even the hexagons without their numbers
if ( _shown.Count == 0 )
{
Log.Info( $"[nz] {list.Count} hex slot(s) placed — {Hidden()}" );
return;
}
for ( var i = 0; i < list.Count; i++ )
{
var go = BuildSlot( list[i], i );
if ( go.IsValid() ) _built.Add( go );
}
Log.Info( $"[nz] {Built} of {list.Count} hex slot(s) built — {Describe( _shown, list.Count )}" );
if ( list.Count > Max )
Log.Warning( $"[nz-hex] {list.Count} hex slots placed, but there are only {Max} numbers and {Max} colours — the"
+ $" {list.Count - Max} past the fourth stay white, with no number (the Hex slot tool's RMB removes one)" );
}
/// <summary>What the slots show, slot by slot.</summary>
static string Describe( Roll r, int slots )
=> string.Join( ", ", Enumerable.Range( 0, slots ).Select( i => $"{i + 1}: {Label( r.NumberOf( i ), r.ColourOf( i ) )}" ) );
GameObject BuildSlot( HexSlotSpot spot, int i )
{
var number = _shown.NumberOf( i );
var colour = _shown.ColourOf( i );
var model = Shape( number, colour );
if ( model is null ) return null;
var go = Scene.CreateObject();
go.Name = $"Hex slot {i + 1} ({Label( number, colour )})";
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( SlotTag );
go.WorldPosition = spot.Position;
go.WorldRotation = spot.Angles.ToRotation();
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
return go;
}
// ══ the look ════════════════════════════════════════════════════════════════════════════
//
// ⚠️ GETTERS OVER NULLABLE FIELDS, the project's rule for numbers that get retuned: an initialiser's value is carried
// across a hotload, so changing a default here would do nothing until a restart.
static float? _radius;
/// <summary>
/// A slot's hexagon, centre to point, in units: 9, so it stands 18u tall.
///
/// ⛔ SIZED TO THE SPOTS, NOT TO LOOK BIG. The first four stand where wall buys were, and a gun's chalk needs little
/// wall. At 16 (32u tall) three of them ran past the edge of their wall: slot 3 has 10u of wall above its centre, the
/// top of a block (`nz_hex_slot_room` measures it). 9 clears every one but slot 4, which stands 3u from the edge of its
/// wall. A slot moved to open wall can be bigger: `nz_hex_slot_size`.
/// </summary>
public static float Radius { get => _radius ?? 9f; set => _radius = value; }
static float? _line;
/// <summary>How wide its strips of light are, in units: 1, a ninth of the radius — bold enough to read at 18u tall.</summary>
public static float Line { get => _line ?? 1f; set => _line = value; }
/// <summary>
/// A slot's light: the tiles' own hexagon, points up and down, and inside it the number as Roman numerals — all in the
/// colour's material. A slot the roll does not cover gets the map's white and no number. Null if the material is
/// missing.
///
/// ⚠️ ROMAN, BECAUSE A STRIP OF LIGHT DRAWS A STRAIGHT LINE. I, II, III and IV are nothing but bars and one V, so they
/// come out as clean as the hexagon around them, and read as marks cut in the stone rather than a sign on the wall.
/// </summary>
static Model Shape( int number, int colour )
{
var material = HexPlatforms.MaterialFor( number > 0 ? colour : -1 );
if ( material is null ) return null;
var s = new Strips();
s.HexRing( Radius, Line );
if ( number > 0 ) s.Numeral( number, Radius * 0.95f, Line );
return s.Build( material );
}
/// <summary>
/// A slot's light, in its own plane (<see cref="LightStrokes"/>, which also draws the napalm icon): the hexagon and the
/// Roman numerals. u runs to the right as you face the wall, v up.
///
/// ⚠️ BUILT IN THE SLOT'S LOCAL SPACE, where the wall's normal is +X (the spot's rotation faces out of the wall). So u is
/// local +Y: the LEFT of something facing out of the wall, which is the right of whoever faces it.
/// </summary>
sealed class Strips : LightStrokes
{
/// <summary>The hexagon's outline, <paramref name="w"/> wide, centred on the circumradius <paramref name="r"/>.</summary>
public void HexRing( float r, float w )
{
// Each side is the band between the hexagon half a strip out and half a strip in. Their corners lie along the
// corner's own direction, w/2 ÷ cos 30° off, which is where two offset sides meet — so the strips mitre.
var k = w * 0.5f / MathF.Cos( MathF.PI / 6f );
for ( var i = 0; i < 6; i++ )
Poly( Corner( i, r + k ), Corner( i + 1, r + k ), Corner( i + 1, r - k ), Corner( i, r - k ) );
}
/// <summary>Corner i of the hexagon at circumradius r: the first straight up, then counter-clockwise.</summary>
static Vector2 Corner( int i, float r )
{
var a = MathF.PI / 2f + i * MathF.PI / 3f;
return new Vector2( r * MathF.Cos( a ), r * MathF.Sin( a ) );
}
/// <summary>A number, 1-4, as Roman numerals <paramref name="h"/> tall and centred: bars, and for IV a V.</summary>
public void Numeral( int number, float h, float w )
{
var gap = w * 1.8f; // the clear space between two pieces
var half = h * 0.3f; // a V's half-width at its top, centre line to centre line
var across = w * MathF.Sqrt( half * half + h * h ) / h; // one of a V's arms, measured flat across
var vWide = 2f * half + across;
// the pieces left to right: true a bar, false a V
var pieces = number switch
{
1 => new[] { true },
2 => new[] { true, true },
3 => new[] { true, true, true },
4 => new[] { true, false },
_ => Array.Empty<bool>(),
};
if ( pieces.Length == 0 ) return;
var total = pieces.Sum( bar => bar ? w : vWide ) + gap * (pieces.Length - 1);
float x = -total / 2f, lo = -h / 2f, hi = h / 2f;
foreach ( var bar in pieces )
{
if ( bar )
{
Poly( new Vector2( x, lo ), new Vector2( x + w, lo ), new Vector2( x + w, hi ), new Vector2( x, hi ) );
x += w + gap;
}
else
{
V( x + vWide / 2f, lo, hi, half, across );
x += vWide + gap;
}
}
}
/// <summary>
/// A V centred on <paramref name="xc"/>: two arms from the top, <paramref name="half"/> either side of the centre,
/// meeting at the bottom. Three pieces that cover it exactly, so nothing is drawn twice.
/// </summary>
void V( float xc, float lo, float hi, float half, float across )
{
var t = across / 2f;
var a = new Vector2( xc - half - t, hi ); // the left arm's top, outside and in
var b = new Vector2( xc - half + t, hi );
var d = new Vector2( xc + half - t, hi ); // the right arm's top, in and outside
var e = new Vector2( xc + half + t, hi );
var g = new Vector2( xc - t, lo ); // the foot
var f = new Vector2( xc + t, lo );
var c = new Vector2( xc, lo + across * (hi - lo) / (2f * half) ); // where the arms' inner edges meet
Poly( a, b, c, g );
Poly( c, d, e, f );
Poly( g, f, c );
}
}
// ══ placing ═════════════════════════════════════════════════════════════════════════════
/// <summary>
/// Place a slot on the surface at <paramref name="point"/>, facing out along <paramref name="normal"/>, and record it in
/// the config. The map editor's tool (LMB), and `nz_hex_slot`.
///
/// ⚠️ A WALL BUY'S PLACEMENT, EXACTLY: 1.5u off the surface, turned to face out of it. The first four WERE wall buys
/// (`nz_hex_slots_from_wallbuys`), so a slot placed by hand sits the way they do.
/// </summary>
public static bool PlaceOn( Vector3 point, Vector3? normal )
{
var cfg = ActiveConfig.Current;
if ( cfg is null ) return false;
// no surface to read (a console placement): face whoever is placing it
var n = normal is Vector3 v && !v.IsNearlyZero() ? v.Normal : FacingPlayer( point );
cfg.HexSlots.Add( new HexSlotSpot { Position = point + n * 1.5f, Angles = Rotation.LookAt( n ).Angles() } );
Ensure()?.Rebuild();
Log.Info( $"[nz-hex] hex slot {cfg.HexSlots.Count} placed at {point}"
+ ( cfg.HexSlots.Count > Max ? $" — that is more than {Max}: the extra stay white, with no number" : "" )
+ ( Instance.IsValid() && Instance._shown.Count == 0 ? $" · {Hidden()} (nz_hex_slots_show 1 shows them)" : "" )
+ " · in memory: Save (nz_save) keeps it" );
return true;
}
static Vector3 FacingPlayer( Vector3 point )
{
var me = NZPlayer.Local;
var d = me.IsValid() ? (me.WorldPosition - point).WithZ( 0f ) : Vector3.Zero;
return d.IsNearlyZero() ? Vector3.Forward : d.Normal;
}
/// <summary>Remove the slot nearest <paramref name="at"/>, within <paramref name="radius"/>. The tool's RMB.</summary>
public static bool RemoveNear( Vector3 at, float radius )
{
var list = ActiveConfig.Current?.HexSlots;
if ( list is null || list.Count == 0 ) return false;
var best = -1;
var bestD = radius;
for ( var i = 0; i < list.Count; i++ )
{
var d = list[i].Position.Distance( at );
if ( d > bestD ) continue;
best = i;
bestD = d;
}
if ( best < 0 ) { Log.Info( "[nz-hex] no hex slot near there" ); return false; }
list.RemoveAt( best );
Ensure()?.Rebuild();
Log.Info( $"[nz-hex] hex slot {best + 1} removed ({list.Count} left) · in memory: Save (nz_save) keeps it" );
return true;
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>`nz_hex_slot` — place a slot on the wall you are aiming at, as the Hex slot tool's LMB does.</summary>
[ConCmd( "nz_hex_slot" )]
public static void PlaceCmd()
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }
var ed = Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();
var hit = ed.IsValid() ? ed.AimTrace() : null;
if ( hit is null ) { Log.Warning( "[nz-hex] aim at a wall first — in Creative, where the map editor runs" ); return; }
PlaceOn( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>
/// `nz_hex_slots` — every slot placed: where it is, and what it shows this game. On the host, while they are hidden, it
/// prints the roll they will show. Testing only: that gives the order away, as `nz_hex_show` gives the picks.
/// </summary>
[ConCmd( "nz_hex_slots" )]
public static void ListCmd()
{
var list = ActiveConfig.Current?.HexSlots;
if ( list is null || list.Count == 0 )
{
Log.Info( "[nz-hex] no hex slots placed — Q > Easter egg > Interactables > Hex slot, or nz_hex_slots_from_wallbuys" );
return;
}
var m = Instance;
var shown = m.IsValid() ? m._shown : default;
var hidden = shown.Count == 0;
for ( var i = 0; i < list.Count; i++ )
{
var s = list[i];
var what = !hidden ? Label( shown.NumberOf( i ), shown.ColourOf( i ) )
: !NZGame.IsClient && m.IsValid() && m._roll.Count > 0 ? $"{Label( m._roll.NumberOf( i ), m._roll.ColourOf( i ) )} (hidden)"
: "hidden";
Log.Info( $"[nz-hex] slot {i + 1}: {what} · at {s.Position} facing yaw {s.Angles.yaw:0}" );
}
Log.Info( $"[nz-hex] {list.Count} placed, {( m.IsValid() ? m.Built : 0 )} standing"
+ ( hidden ? $" · {Hidden()} (nz_hex_slots_show 1 shows them)" : " · rolled again at every round's end (nz_hex_slots_roll rolls now)" ) );
}
/// <summary>
/// `nz_hex_slots_show [1|0]` — HOST: `1` puts the slots on the walls with the power off, to look at them or place them.
/// Testing only: it gives the order away. `0` goes back to following the power. Bare, it says what they show and why.
/// </summary>
[ConCmd( "nz_hex_slots_show" )]
public static void ShowCmd( int show = -1 )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
var m = Ensure();
if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no scene" ); return; }
if ( show >= 0 )
{
m._forced = show > 0;
m.SendShown();
}
Log.Info( $"[nz-hex] the slots are {( m._shown.Count > 0 ? "on the walls" : "hidden" )} · power {( Power.IsOn ? "on" : "off" )}"
+ ( m._forced ? " · SHOWN BY HAND (nz_hex_slots_show 0 undoes)" : " · they follow the power" ) );
}
/// <summary>`nz_hex_slots_roll` — HOST: roll the slots again now, as a round's end does: following the roll before.</summary>
[ConCmd( "nz_hex_slots_roll" )]
public static void RollCmd()
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
if ( Placed == 0 ) { Log.Info( "[nz-hex] no hex slots placed — nothing to roll" ); return; }
var m = Ensure();
if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no scene" ); return; }
m.RollNow( "by hand" );
}
/// <summary>`nz_hex_slots_clear` — remove every slot from the config.</summary>
[ConCmd( "nz_hex_slots_clear" )]
public static void ClearCmd()
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }
var list = ActiveConfig.Current?.HexSlots;
var n = list?.Count ?? 0;
list?.Clear();
Ensure()?.Rebuild();
Log.Info( $"[nz-hex] removed {n} hex slot(s) · in memory: Save (nz_save) keeps it" );
}
/// <summary>
/// `nz_hex_slots_from_wallbuys [weapon]` — HOST: turn every wall buy selling this weapon into a slot, in the same place
/// and facing the same way, and remove the wall buy. The weapon is matched anywhere in its prefab's path. The default is
/// the .410 Jury (`taurus_judge`), which is what the first four were placed as. In memory, like every edit: Save keeps it.
/// </summary>
[ConCmd( "nz_hex_slots_from_wallbuys" )]
public static void FromWallBuys( string weapon = "taurus_judge" )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }
var cfg = ActiveConfig.Current;
var match = weapon?.Trim() ?? "";
if ( cfg is null || match == "" ) return;
var hits = cfg.WallBuys
.Where( w => w.WeaponPrefab is not null && w.WeaponPrefab.Contains( match, StringComparison.OrdinalIgnoreCase ) )
.ToList();
if ( hits.Count == 0 )
{
Log.Warning( $"[nz-hex] no wall buy sells '{match}' — nz_wallbuy_list shows what they sell" );
return;
}
foreach ( var w in hits )
{
cfg.HexSlots.Add( new HexSlotSpot { Position = w.Position, Angles = w.Angles } );
cfg.WallBuys.Remove( w );
}
WallBuyManager.Ensure()?.Rebuild();
Ensure()?.Rebuild();
Log.Info( $"[nz-hex] {hits.Count} wall buy(s) selling '{match}' became hex slots, where they stood — {cfg.HexSlots.Count}"
+ $" slot(s) placed now, {cfg.WallBuys.Count} wall buy(s) left · in memory: Save (nz_save) keeps it" );
if ( cfg.HexSlots.Count > Max )
Log.Warning( $"[nz-hex] that is {cfg.HexSlots.Count} slots, and only {Max} can have a number and a colour" );
}
/// <summary>
/// `nz_hex_slot_size [radius] [line]` — how big a slot's hexagon is, centre to point, and how wide its strips; bare, it
/// prints them. On this machine and until a restart: a size is chosen by looking at it, so settle on one here, then set
/// it as the default in `Radius` and `Line`.
/// </summary>
[ConCmd( "nz_hex_slot_size" )]
public static void SizeCmd( float radius = -1f, float line = -1f )
{
if ( radius > 0f ) Radius = radius;
if ( line > 0f ) Line = line;
if ( (radius > 0f || line > 0f) && Instance.IsValid() ) Instance.Build();
Log.Info( $"[nz-hex] a slot is {Radius:0.#}u centre to point ({2f * Radius:0.#}u tall), its strips {Line:0.##}u wide" );
}
/// <summary>
/// `nz_hex_slot_nudge <slot> <right> [up]` — HOST: slide a slot along its wall, in units: to the right as you face
/// it (negative to the left), and up. For the last few units, without placing it again. In memory: Save keeps it.
/// </summary>
[ConCmd( "nz_hex_slot_nudge" )]
public static void NudgeCmd( int slot, float right, float up = 0f )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }
var list = ActiveConfig.Current?.HexSlots;
if ( list is null || slot < 1 || slot > list.Count ) { Log.Warning( $"[nz-hex] no slot {slot} — there are {list?.Count ?? 0}" ); return; }
var s = list[slot - 1];
var rot = s.Angles.ToRotation();
s.Position += rot.Left * right + rot.Up * up; // its left is the right of whoever faces it
Ensure()?.Rebuild();
Log.Info( $"[nz-hex] slot {slot} moved {right:0.#}u right and {up:0.#}u up, to {s.Position} · in memory: Save (nz_save) keeps it" );
}
/// <summary>
/// `nz_hex_slot_room [slot]` — how big a hexagon each slot's wall has room for, which way it runs out and why, and so the
/// largest `nz_hex_slot_size` that fits every slot as it is placed. With a slot number, every direction's reading too.
/// </summary>
[ConCmd( "nz_hex_slot_room" )]
public static void RoomCmd( int slot = 0 )
{
var list = ActiveConfig.Current?.HexSlots;
var scene = Game.ActiveScene;
if ( list is null || list.Count == 0 || !scene.IsValid() ) { Log.Info( "[nz-hex] no hex slots placed" ); return; }
var me = NZPlayer.Local;
var ignore = me.IsValid() ? me.GameObject : null;
var all = float.MaxValue;
for ( var i = 0; i < list.Count; i++ )
{
if ( slot > 0 && i != slot - 1 ) continue;
var room = RoomFor( list[i], scene, ignore, slot > 0 );
all = MathF.Min( all, room.Fit );
Log.Info( $"[nz-hex] slot {i + 1}: room for a radius of {room.Fit:0.#}u ({2f * room.Fit:0.#}u tall) — tightest"
+ $" {Heading( room.Deg )}, where {room.Why} {room.Free:0.#}u out"
+ ( room.Fit < Radius ? $" · at {Radius:0.#}u now it runs past it" : "" ) );
}
if ( slot <= 0 )
Log.Info( $"[nz-hex] {Radius:0.#}u now · up to {all:0.#}u fits every slot where it is"
+ $" — nz_hex_slot_size {MathF.Max( 0.5f, MathF.Floor( all * 2f ) / 2f ):0.#}" );
}
/// <summary>A direction in a slot's face, as whoever faces the wall sees it.</summary>
static string Heading( int deg ) => deg switch
{
0 => "to the right", 90 => "up", 180 => "to the left", 270 => "down",
< 90 => $"up-right ({deg}°)", < 180 => $"up-left ({deg}°)", < 270 => $"down-left ({deg}°)", _ => $"down-right ({deg}°)",
};
/// <summary>
/// How much flat wall a slot has: from its centre, every 10°, how far the wall it is on runs before it ends or something
/// stands in front of it. Fit is the largest <see cref="Radius"/> whose hexagon fits, its strips' outer edge included;
/// Deg, Free and Why say which direction decided it, how far the wall ran there, and what stopped it.
///
/// ⚠️ THE WALL IS FOUND BY TRACING THROUGH IT, NOT ALONG IT: at each step a short ray from 4.5u in front of the wall's
/// plane to 12u behind it. The slot sits 1.5u out, so the wall is there while the ray stops about 4.5u in; an edge lets
/// it through, and anything standing in front stops it short.
///
/// ⛔ A SMALL STEP IS NOT AN EDGE. The first version stopped at any step of more than ¾u, and put slot 4 at 0.6u of room:
/// it stands at the edge of a strip of wall raised 1.5u. A hexagon 1.5u out still clears that, and only looks wrong
/// where the wall falls away under it (more than 4u back) or where something comes out through it (in front of the
/// slot's own 1.5u).
/// </summary>
static (float Fit, int Deg, float Free, string Why) RoomFor( HexSlotSpot spot, Scene scene, GameObject ignore, bool verbose )
{
var rot = spot.Angles.ToRotation();
var n = rot.Forward;
var best = (Fit: float.MaxValue, Deg: 0, Free: 0f, Why: "");
for ( var deg = 0; deg < 360; deg += 10 )
{
var a = deg * MathF.PI / 180f;
var dir = rot.Left * MathF.Cos( a ) + rot.Up * MathF.Sin( a ); // in the slot's face: u right, v up
var free = 0f;
var why = "the wall runs on past";
for ( var d = 0.5f; d <= 48f; d += 0.5f )
{
var p = spot.Position + dir * d;
var t = scene.Trace.Ray( p + n * 3f, p - n * 13.5f );
if ( ignore.IsValid() ) t = t.IgnoreGameObjectHierarchy( ignore );
var tr = t.Run();
if ( !tr.Hit || tr.Distance > 8.5f ) { why = "the wall ends (or falls back more than 4u)"; break; }
if ( tr.Distance < 2.75f )
{
why = $"something comes {4.5f - tr.Distance:0.#}u out of the wall, through the slot ({tr.GameObject?.Name ?? "?"})";
break;
}
free = d;
}
// a hexagon with its points up reaches apothem ÷ cos φ along a direction φ off its nearest flat's normal
var phi = ((deg + 30) % 60 - 30) * MathF.PI / 180f;
var fit = free * MathF.Cos( phi ) / MathF.Cos( MathF.PI / 6f ) - Line * 0.5f / MathF.Cos( MathF.PI / 6f );
if ( verbose ) Log.Info( $"[nz-hex] {deg,3}° {Heading( deg )}: {free:0.#}u, then {why}" );
if ( fit < best.Fit ) best = (fit, deg, free, why);
}
return best;
}
/// <summary>
/// `nz_hex_slot_photo [slot] [distance] [angle]` — a picture of a slot from in front of it, straight on or turned
/// <paramref name="angle"/> degrees round it (positive to its right, as you face it), saved as
/// `nz_hex_slot_<n>.png` in the game's data folder, to check one without walking there. Needs no game: it only
/// renders.
///
/// ⚠️ A THROWAWAY CAMERA, NOT THE PLAYER'S: made, rendered once into a bitmap, destroyed. It is not the main camera and
/// sits far below it in priority, so the screen never shows it.
/// </summary>
[ConCmd( "nz_hex_slot_photo" )]
public static void PhotoCmd( int slot = 1, float distance = 72f, float angle = 0f )
{
var list = ActiveConfig.Current?.HexSlots;
var scene = Game.ActiveScene;
if ( list is null || slot < 1 || slot > list.Count || !scene.IsValid() )
{
Log.Warning( $"[nz-hex] no slot {slot} — there are {list?.Count ?? 0}" );
return;
}
var face = list[slot - 1].Angles.ToRotation();
var go = scene.CreateObject();
go.Name = "nz_hex_slot_photo camera";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// turned round the slot's own up: its left is the right of whoever faces it
var toward = Rotation.FromAxis( face.Up, angle ) * face.Forward;
go.WorldPosition = list[slot - 1].Position + toward * distance;
go.WorldRotation = Rotation.LookAt( -toward, face.Up );
var cam = go.Components.Create<CameraComponent>();
cam.IsMainCamera = false;
cam.Priority = -1000;
cam.FieldOfView = 60f;
try
{
using var pic = new Bitmap( 960, 720 );
cam.RenderToBitmap( pic );
var file = angle == 0f ? $"nz_hex_slot_{slot}.png" : $"nz_hex_slot_{slot}_{angle:0}.png";
FileSystem.Data.WriteAllBytes( file, pic.ToPng() );
Log.Info( $"[nz-hex] slot {slot} from {distance:0}u in front{( angle == 0f ? "" : $", {angle:0}° round" )}"
+ $" -> {FileSystem.Data.GetFullPath( file )}"
+ ( Instance.IsValid() && Instance._shown.Count > 0 ? "" : $" — the slots are {Hidden()}, so it is bare wall: nz_hex_slots_show 1" ) );
}
catch ( Exception e )
{
Log.Warning( $"[nz-hex] could not photograph slot {slot}: {e.Message}" );
}
finally
{
go.Destroy();
}
}
/// <summary>
/// `nz_hex_slots_selftest` — HOST: check the roll's rules in code — no number and no colour twice, every order possible,
/// the packing exact — that each round's roll changes every slot's number, every slot's colour and every colour's
/// number, that nothing shows until the power is on, and that the slots shown are the ones rolled, in their colours.
/// Puts this game's roll back when it is done, and the power's say over it.
///
/// ⚠️ IT GOES THROUGH THE REAL ROLL AND BROADCAST, as `nz_hex_selftest` does, because testing a copy of the logic would
/// prove the copy. On a networked game the other players see the slots change once, and back.
/// </summary>
[ConCmd( "nz_hex_slots_selftest" )]
public static void SelfTest()
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
// ⚠️ NO GAME, STILL A TEST: the roll's own rules need nothing, so they run in the editor too. The round's end, the
// power and the walls need a game, and are skipped — and said to be — without one.
var m = Ensure();
var saved = m.IsValid() ? m._roll : default;
var savedForced = m.IsValid() && m._forced;
int pass = 0, fail = 0;
void Check( bool ok, string what )
{
if ( ok ) pass++; else fail++;
Log.Info( $"[nz-hex-test] {( ok ? "ok " : "FAIL" )} {what}" );
}
int[] Numbers( Roll r ) => Enumerable.Range( 0, Max ).Select( i => r.NumberOf( i ) ).ToArray();
int[] Colours( Roll r ) => Enumerable.Range( 0, Max ).Select( i => r.ColourOf( i ) ).ToArray();
// 1. a roll of four: the numbers 1-4 once each, the four colours once each
var rolls = Enumerable.Range( 0, 600 ).Select( _ => Make( Max ) ).ToList();
Check( rolls.All( r => r.Count == Max
&& Numbers( r ).OrderBy( x => x ).SequenceEqual( new[] { 1, 2, 3, 4 } )
&& Colours( r ).OrderBy( x => x ).SequenceEqual( new[] { 0, 1, 2, 3 } ) ),
"600 rolls of four: each has the numbers 1-4 once, and the four colours once" );
// 2. every order comes up — the numbers', the colours', and which number goes with which colour
Check( rolls.Select( r => string.Concat( Numbers( r ) ) ).Distinct().Count() == 24, "…all 24 orders of the numbers come up" );
Check( rolls.Select( r => string.Concat( Colours( r ) ) ).Distinct().Count() == 24, "…all 24 orders of the colours come up" );
Check( rolls.Select( r => r.NumberOf( 0 ) * 10 + r.ColourOf( 0 ) ).Distinct().Count() == 16,
"…and slot 1 gets all 16 pairs of a number and a colour: neither decides the other" );
// 3. the packing: every roll of four comes back out exactly as it went in
var orders = Orders( Max );
var exact = true;
foreach ( var ns in orders )
foreach ( var cs in orders )
{
var r = Roll.Of( ns.Select( k => k + 1 ).ToList(), cs );
for ( var i = 0; i < Max; i++ )
exact &= r.NumberOf( i ) == ns[i] + 1 && r.ColourOf( i ) == cs[i];
exact &= r.Count == Max && r.NumberOf( Max ) == 0 && r.ColourOf( Max ) == -1;
}
Check( exact, $"all {orders.Count * orders.Count} rolls of four pack into one int and come back out exactly" );
// 4. fewer than four slots, more than four, none
var three = Make( 3 );
var n3 = Enumerable.Range( 0, 3 ).Select( i => three.NumberOf( i ) ).ToList();
var c3 = Enumerable.Range( 0, 3 ).Select( i => three.ColourOf( i ) ).ToList();
Check( three.Count == 3 && n3.Distinct().Count() == 3 && n3.All( x => x is >= 1 and <= 4 )
&& c3.Distinct().Count() == 3 && c3.All( x => x is >= 0 and <= 3 ) && three.NumberOf( 3 ) == 0,
"three slots: three different numbers from 1-4 and three different colours, and no fourth" );
Check( Make( 9 ).Count == Max, "nine slots: only four are rolled" );
Check( default( Roll ).Count == 0 && default( Roll ).NumberOf( 0 ) == 0 && default( Roll ).ColourOf( 0 ) == -1,
"no roll shows nothing" );
// 5. the next round: every slot's number and colour change, and every colour's number. The rule is written out here
// on its own, the way it was asked ("if red IV was on slot 2, next round red cannot be IV and cannot be on slot 2"),
// so this does not just run `Follows` again.
bool AsAsked( Roll a, Roll b )
{
for ( var c = 0; c < HexPlatforms.Colours; c++ )
{
var was = Enumerable.Range( 0, a.Count ).Where( i => a.ColourOf( i ) == c ).DefaultIfEmpty( -1 ).First();
var now = Enumerable.Range( 0, b.Count ).Where( i => b.ColourOf( i ) == c ).DefaultIfEmpty( -1 ).First();
if ( was >= 0 && now >= 0 && (now == was || b.NumberOf( now ) == a.NumberOf( was )) ) return false;
}
return a.Count == b.Count && Enumerable.Range( 0, a.Count ).All( i => b.NumberOf( i ) != a.NumberOf( i ) );
}
bool Fair( Roll r, int n ) => r.Count == n
&& Enumerable.Range( 0, n ).Select( i => r.NumberOf( i ) ).Distinct().Count() == n
&& Enumerable.Range( 0, n ).All( i => r.NumberOf( i ) is >= 1 and <= 4 )
&& Enumerable.Range( 0, n ).Select( i => r.ColourOf( i ) ).Distinct().Count() == n
&& Enumerable.Range( 0, n ).All( i => r.ColourOf( i ) is >= 0 and <= 3 );
var rounds = true;
for ( var k = 0; k < 300 && rounds; k++ )
{
var prev = Make( Max );
var next = Next( prev, Max );
rounds = Fair( next, Max ) && AsAsked( prev, next );
}
Check( rounds, "300 rounds: each next roll changes every slot's number, every slot's colour and every colour's number" );
var start = Make( Max );
var allowed = new HashSet<int>();
foreach ( var ns in orders )
foreach ( var cs in orders )
{
var r = Roll.Of( ns.Select( k => k + 1 ).ToList(), cs );
if ( AsAsked( start, r ) ) allowed.Add( r.Packed );
}
var drawn = Enumerable.Range( 0, 400 ).Select( _ => Next( start, Max ).Packed ).ToHashSet();
Check( allowed.Count > 0 && drawn.SetEquals( allowed ),
$"…from one roll, all {allowed.Count} rolls the rule allows come up ({drawn.Count} seen), and no other" );
var three3 = true;
for ( var k = 0; k < 100 && three3; k++ )
{
var prev = Make( 3 );
var next = Next( prev, 3 );
three3 = Fair( next, 3 ) && AsAsked( prev, next );
}
Check( three3, "…and with three slots too" );
if ( !m.IsValid() )
{
Log.Info( $"[nz-hex-test] {pass} passed, {fail} failed — the roll's own rules only. No game is running, so the"
+ " round's end, the power and the walls were not checked: run it again in one" );
return;
}
if ( Placed > 0 )
{
if ( m._roll.Count != Math.Min( Placed, Max ) ) m.RollNow( "the selftest" );
var before = m._roll;
OnRoundEnd( 0, "the selftest's round end" );
Check( m._roll != before && Fair( m._roll, before.Count ) && AsAsked( before, m._roll ),
"a round's end rolls them again, by the rule" );
}
else
Check( true, "a round's end: nothing placed, so nothing to roll" );
// 6. the power: nothing shows while it is off, the roll once it is on — and what went out is what the power says now
var placed = Placed;
m._forced = false;
m.RollNow( "the selftest" );
var sent = m._roll;
Check( sent.Count == Math.Min( placed, Max ), $"a roll covers {sent.Count} of the {placed} placed" );
Check( m.ShownFor( false ).Count == 0, "…the slots show nothing while the power is off" );
Check( m.ShownFor( true ) == sent, "…and the roll once it is on" );
var showsNow = Power.IsOn && !HexPlatforms.StepDone;
Check( m._shown == m.ShownFor( showsNow ) && m.Built == (showsNow ? placed : 0),
$"…and with the power {( Power.IsOn ? "on" : "off" )}{( HexPlatforms.StepDone ? " and the step done" : "" )} now,"
+ $" {m.Built} of {placed} stand on the walls" );
// 7. shown — by hand, whatever the power — each stands where its spot is, in its colour's material
m._forced = true;
m.SendShown();
Check( m._shown == sent && m.Built == placed, $"shown by hand, every placed slot stands: {m.Built} of {placed}" );
var list = ActiveConfig.Current?.HexSlots ?? new List<HexSlotSpot>();
var built = m._built.Where( g => g.IsValid() ).ToList();
var wrong = new List<string>();
for ( var i = 0; i < built.Count && i < list.Count; i++ )
{
var go = built[i];
var want = HexPlatforms.MaterialFor( sent.NumberOf( i ) > 0 ? sent.ColourOf( i ) : -1 );
var mats = go.Components.Get<ModelRenderer>()?.Model?.Materials.Select( x => x?.Name ).ToList() ?? new List<string>();
if ( want is null || !mats.Contains( want.Name ) )
wrong.Add( $"slot {i + 1} draws in [{string.Join( ", ", mats )}], not {want?.Name}" );
if ( go.WorldPosition.Distance( list[i].Position ) > 0.01f
|| go.WorldRotation.Forward.Distance( list[i].Angles.ToRotation().Forward ) > 0.001f )
wrong.Add( $"slot {i + 1} is not where its spot is" );
}
Check( wrong.Count == 0, placed == 0
? "…nothing placed, so nothing to look at on the walls"
: wrong.Count == 0 ? "…each where its spot is, in its colour's material" : string.Join( "; ", wrong ) );
// put this game's roll back, and the power's say over it
m._roll = saved;
m._forced = savedForced;
m.SendShown();
Log.Info( $"[nz-hex-test] {pass} passed, {fail} failed — this game's roll restored" );
}
/// <summary>Every order of 0 … n−1.</summary>
static List<int[]> Orders( int n )
{
var all = new List<int[]>();
void Walk( int[] a, int k )
{
if ( k == n ) { all.Add( a.ToArray() ); return; } // ⚠️ NOT a.Clone(): the whitelist refuses Array.Clone
for ( var i = k; i < n; i++ )
{
(a[k], a[i]) = (a[i], a[k]);
Walk( a, k + 1 );
(a[k], a[i]) = (a[i], a[k]);
}
}
Walk( Enumerable.Range( 0, n ).ToArray(), 0 );
return all;
}
}