feat(insights): blog setup — hub page + Single Post template + 3 seed posts
Phase 12 (post-launch ready) — Insights blog scaffolding so the /insights/ menu link works and the user can start publishing. Creates : 1. /insights/ hub page (#153) with hero + Bricks `posts` query-loop element + closer CTA. The posts element configured with : - query : post_type=post, posts_per_page=9, orderby=date DESC - layout=grid, columns=3, linkPost=true, imageDisable=true - fields : eyebrow "Insights", h3 post title, date, excerpt - _cssCustom for card styling (subtle bg, border, padding, flex column) 2. Single Post Bricks template (#154, template_type=single, conditions= singular post) : eyebrow + h1 {post_title} + date · author meta + featured image + post-content + closer CTA. 3. Three seed posts publication-ready : - the-10-to-15-gap (#155) - simulators-for-entertainment-vs-training (#156) - sovereign-by-default-gdpr-native (#157) Topics aligned with the brand : training gap reality, why entertainment-first simulators fail tactical training, GDPR-native sovereignty for security forces. User adds more posts via WP Admin → Posts → Add New. The Bricks Single template handles layout automatically (matches templateConditions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
470
tools/av-generate-insights.php
Normal file
470
tools/av-generate-insights.php
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Asterion — Generate Insights blog setup (one-shot)
|
||||||
|
* Description: /?av_generate_insights=1 creates :
|
||||||
|
* 1. The /insights/ hub page (hero + Bricks posts query-loop + closer CTA)
|
||||||
|
* 2. A Bricks Single Post template (applied to all posts site-wide)
|
||||||
|
* 3. Three seed posts (sample content) so the hub isn't empty
|
||||||
|
*
|
||||||
|
* Idempotent : re-runs update without duplicating.
|
||||||
|
*/
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_av-bricks-shared.php';
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------
|
||||||
|
FR strings dict for Insights
|
||||||
|
----------------------------------------------------------------- */
|
||||||
|
add_filter('av_fr_translation_dict', function ($dict) {
|
||||||
|
return array_merge($dict, [
|
||||||
|
'Insights' => 'Analyses',
|
||||||
|
'Field notes from the platform.' => 'Notes de terrain depuis la plateforme.',
|
||||||
|
'Operational realities, tactical considerations, and product updates from the Asterion team. We publish when we have something worth saying.'
|
||||||
|
=> "Réalités opérationnelles, considérations tactiques et mises à jour produit de l'équipe Asterion. Nous publions quand nous avons quelque chose à dire.",
|
||||||
|
'Read article' => "Lire l'article",
|
||||||
|
'Read more' => 'Lire la suite',
|
||||||
|
'Latest articles' => 'Derniers articles',
|
||||||
|
'No articles yet — check back soon.' => "Pas encore d'article — revenez bientôt.",
|
||||||
|
'Subscribe to updates' => 'S\'abonner aux mises à jour',
|
||||||
|
|
||||||
|
// Seed posts FR titles + excerpts
|
||||||
|
'The 10-to-15 gap : why 90 cartridges per year became a fiction'
|
||||||
|
=> "L'écart 10 à 15 : pourquoi 90 cartouches par an est devenu une fiction",
|
||||||
|
'French regulation sets a clear floor for police marksmanship training. Reality, almost everywhere, falls short. Here is what the gap looks like, why it persists, and what closing it operationally actually means.'
|
||||||
|
=> "La réglementation française fixe un plancher clair pour le tir police. La réalité, presque partout, est en deçà. Voici à quoi ressemble cet écart, pourquoi il persiste et ce que le combler signifie en pratique.",
|
||||||
|
|
||||||
|
'Why simulators built for entertainment break in tactical training'
|
||||||
|
=> "Pourquoi les simulateurs conçus pour le divertissement cassent en entraînement tactique",
|
||||||
|
'Most VR shooting simulators were engineered with one priority : visual realism. For tactical training, that is the wrong priority. Here is what we learned building the platform the other way around.'
|
||||||
|
=> "La plupart des simulateurs VR ont été conçus avec une priorité : le réalisme visuel. Pour l'entraînement tactique, c'est la mauvaise priorité. Voici ce qu'on a appris en construisant la plateforme dans l'autre sens.",
|
||||||
|
|
||||||
|
'Sovereign by default : what GDPR-native actually means for security forces'
|
||||||
|
=> "Souverain par défaut : ce que RGPD-natif veut vraiment dire pour les forces de sécurité",
|
||||||
|
'There is a difference between a product that complies with GDPR and one designed for it from the start. For training data \u{2014} sensitive by definition \u{2014} the difference matters.'
|
||||||
|
=> "Il y a une différence entre un produit conforme RGPD et un produit conçu pour l'être dès l'origine. Pour les données d'entraînement \u{2014} sensibles par définition \u{2014} cette différence compte.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------
|
||||||
|
URL trigger
|
||||||
|
----------------------------------------------------------------- */
|
||||||
|
add_action('template_redirect', function () {
|
||||||
|
if (empty($_GET['av_generate_insights'])) return;
|
||||||
|
if (! in_array($_SERVER['SERVER_NAME'] ?? '', ['localhost', '127.0.0.1'], true)) wp_die('localhost only');
|
||||||
|
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
/* 1. Hub page /insights/ */
|
||||||
|
$hub_id = av_ensure_hub_page('insights', 'Insights');
|
||||||
|
$elements = av_build_insights_hub_elements();
|
||||||
|
$write = av_write_bricks_page_elements($hub_id, $elements);
|
||||||
|
echo "Hub /insights/ → page #$hub_id — " . count($elements) . " elements (json={$write['json_len']}, stored={$write['stored_len']})\n\n";
|
||||||
|
|
||||||
|
/* 2. Bricks Single Post template */
|
||||||
|
$tpl_id = av_ensure_insight_single_template();
|
||||||
|
echo "Single Post template → bricks_template #$tpl_id\n\n";
|
||||||
|
|
||||||
|
/* 3. Seed posts */
|
||||||
|
echo "Seed posts :\n";
|
||||||
|
$posts = av_get_insight_seed_posts();
|
||||||
|
foreach ($posts as $p) {
|
||||||
|
$existing = get_page_by_path($p['slug'], OBJECT, 'post');
|
||||||
|
if ($existing) {
|
||||||
|
wp_update_post([
|
||||||
|
'ID' => $existing->ID,
|
||||||
|
'post_title' => $p['title'],
|
||||||
|
'post_excerpt' => $p['excerpt'],
|
||||||
|
'post_content' => $p['content'],
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_date' => $p['date'],
|
||||||
|
]);
|
||||||
|
echo " '{$p['title']}' → post #{$existing->ID} (updated)\n";
|
||||||
|
} else {
|
||||||
|
$new = wp_insert_post([
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_title' => $p['title'],
|
||||||
|
'post_name' => $p['slug'],
|
||||||
|
'post_excerpt' => $p['excerpt'],
|
||||||
|
'post_content' => $p['content'],
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_date' => $p['date'],
|
||||||
|
]);
|
||||||
|
echo " '{$p['title']}' → post #$new (created, slug={$p['slug']})\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nDONE. Visit /insights/ to see the blog hub. Click any post for the single template.\n";
|
||||||
|
echo "Write more posts via WP Admin → Posts → Add New.\n";
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------
|
||||||
|
HUB PAGE BUILDER : hero + posts query-loop + closer
|
||||||
|
----------------------------------------------------------------- */
|
||||||
|
function av_build_insights_hub_elements() {
|
||||||
|
$els = [];
|
||||||
|
|
||||||
|
// HERO
|
||||||
|
$els = array_merge($els, av_build_hero('ins', [
|
||||||
|
'eyebrow' => 'Insights',
|
||||||
|
'tagline' => 'Field notes from the platform.',
|
||||||
|
'hero_lead' => 'Operational realities, tactical considerations, and product updates from the Asterion team. We publish when we have something worth saying.',
|
||||||
|
'ctas' => [
|
||||||
|
['label' => 'Subscribe to updates', 'href' => '/contact/', 'variant' => 'primary'],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
// POSTS QUERY-LOOP SECTION
|
||||||
|
$sid = 'sinslst';
|
||||||
|
$oct = 'oinslst';
|
||||||
|
$nrr = 'ninslst';
|
||||||
|
$eyb = 'einslst';
|
||||||
|
$hId = 'hinslst';
|
||||||
|
$pst = 'pinslst'; // posts element
|
||||||
|
|
||||||
|
$els[] = [
|
||||||
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
||||||
|
'settings' => array_merge(av_section_padding_responsive(), [
|
||||||
|
'_background' => ['color' => av_color('var(--color-bg-default)')],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr, $pst],
|
||||||
|
'settings' => [
|
||||||
|
'_direction' => 'column',
|
||||||
|
'_widthMax' => '1280px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
||||||
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
||||||
|
'_alignItems' => 'flex-start',
|
||||||
|
'_rowGap' => '2rem',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => [$eyb, $hId],
|
||||||
|
'settings' => [
|
||||||
|
'_direction' => 'column',
|
||||||
|
'_widthMax' => '800px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
||||||
|
'_padding' => av_sp('0', '0', '0', '0'),
|
||||||
|
'_rowGap' => '0.75rem',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = av_eyebrow($eyb, $nrr, 'Latest articles');
|
||||||
|
$els[] = av_heading($hId, $nrr, 'h2', 'Field notes from the platform.');
|
||||||
|
|
||||||
|
// The Bricks `posts` element. Each card's content is defined by the
|
||||||
|
// `fields` repeater (dynamic data + tag + styling per field).
|
||||||
|
// Without these fields, Bricks renders empty <li> wrappers only.
|
||||||
|
$els[] = [
|
||||||
|
'id' => $pst, 'name' => 'posts', 'parent' => $oct, 'children' => [],
|
||||||
|
'settings' => [
|
||||||
|
'query' => [
|
||||||
|
'objectType' => 'post',
|
||||||
|
'post_type' => 'post',
|
||||||
|
'posts_per_page' => 9,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
],
|
||||||
|
'columns' => 3,
|
||||||
|
'gutter' => 24,
|
||||||
|
'layout' => 'grid',
|
||||||
|
'linkPost' => true, // make the whole card clickable
|
||||||
|
'imageDisable' => true, // no featured image for now
|
||||||
|
// Card structure
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'id' => substr(md5('insf-eyebrow'), 0, 6),
|
||||||
|
'dynamicData' => 'Insights',
|
||||||
|
'tag' => 'div',
|
||||||
|
'dynamicTypography' => [
|
||||||
|
'color' => av_color('var(--color-brand-gold)'),
|
||||||
|
'font-size' => '0.75rem',
|
||||||
|
'font-weight' => '600',
|
||||||
|
'text-transform' => 'uppercase',
|
||||||
|
'letter-spacing' => '0.08em',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => substr(md5('insf-title'), 0, 6),
|
||||||
|
'dynamicData' => '{post_title}',
|
||||||
|
'tag' => 'h3',
|
||||||
|
'dynamicTypography' => [
|
||||||
|
'color' => av_color('var(--color-text-primary)'),
|
||||||
|
'font-size' => '1.125rem',
|
||||||
|
'font-weight' => '700',
|
||||||
|
'line-height' => '1.35',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => substr(md5('insf-meta'), 0, 6),
|
||||||
|
'dynamicData' => '{post_date}',
|
||||||
|
'tag' => 'div',
|
||||||
|
'dynamicTypography' => [
|
||||||
|
'color' => av_color('var(--color-text-tertiary)'),
|
||||||
|
'font-size' => '0.8125rem',
|
||||||
|
'line-height' => '1.4',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => substr(md5('insf-excerpt'), 0, 6),
|
||||||
|
'dynamicData' => '{post_excerpt}',
|
||||||
|
'tag' => 'p',
|
||||||
|
'dynamicTypography' => [
|
||||||
|
'color' => av_color('var(--color-text-secondary)'),
|
||||||
|
'font-size' => '0.9375rem',
|
||||||
|
'line-height' => '1.55',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Card wrapper styling via custom CSS (Bricks's repeater wrapper :
|
||||||
|
// .bricks-layout-inner is the card body)
|
||||||
|
'_cssCustom' => "%root% .bricks-layout-inner { padding: 1.5rem; background: var(--color-bg-subtle); border: 1px solid var(--color-border-subtle); border-radius: 4px; display: flex; flex-direction: column; gap: 0.6rem; height: 100%; }\n%root% .bricks-layout-item { display: flex; }\n%root% .bricks-layout-item a { text-decoration: none; }",
|
||||||
|
'_width' => '100%',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// CLOSER CTA
|
||||||
|
$els = array_merge($els, av_build_closer_cta('ins'));
|
||||||
|
|
||||||
|
return $els;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------
|
||||||
|
BRICKS SINGLE POST TEMPLATE : hero (title + meta) + content + closer
|
||||||
|
----------------------------------------------------------------- */
|
||||||
|
function av_ensure_insight_single_template() {
|
||||||
|
// Find existing or create
|
||||||
|
$existing = get_posts([
|
||||||
|
'post_type' => 'bricks_template',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'name' => 'single-post-asterion',
|
||||||
|
'numberposts' => 1,
|
||||||
|
]);
|
||||||
|
if ($existing) {
|
||||||
|
$tpl_id = $existing[0]->ID;
|
||||||
|
wp_update_post(['ID' => $tpl_id, 'post_title' => 'Single Post — Asterion']);
|
||||||
|
} else {
|
||||||
|
$tpl_id = wp_insert_post([
|
||||||
|
'post_type' => 'bricks_template',
|
||||||
|
'post_title' => 'Single Post — Asterion',
|
||||||
|
'post_name' => 'single-post-asterion',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as type=single + apply to all single posts
|
||||||
|
update_post_meta($tpl_id, '_bricks_template_type', 'single');
|
||||||
|
$settings = get_post_meta($tpl_id, '_bricks_template_settings', true) ?: [];
|
||||||
|
$settings['templateConditions'] = [
|
||||||
|
['id' => substr(md5('postSingle'), 0, 6), 'main' => 'singular', 'object' => 'post'],
|
||||||
|
];
|
||||||
|
update_post_meta($tpl_id, '_bricks_template_settings', $settings);
|
||||||
|
|
||||||
|
// Build template content
|
||||||
|
$elements = av_build_insight_single_elements();
|
||||||
|
av_write_bricks_page_elements($tpl_id, $elements);
|
||||||
|
|
||||||
|
return $tpl_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function av_build_insight_single_elements() {
|
||||||
|
$els = [];
|
||||||
|
|
||||||
|
// 1. Hero — eyebrow + post title + meta + featured image
|
||||||
|
$sid = 'spostsec';
|
||||||
|
$oct = 'spostoc';
|
||||||
|
$nrr = 'spostnr';
|
||||||
|
$eyb = 'spostey';
|
||||||
|
$hId = 'sposth1';
|
||||||
|
$met = 'spostmt';
|
||||||
|
$img = 'spostim';
|
||||||
|
|
||||||
|
$els[] = [
|
||||||
|
'id' => $sid, 'name' => 'section', 'parent' => 0, 'children' => [$oct],
|
||||||
|
'settings' => [
|
||||||
|
'_padding' => av_sp('5rem', '0', '4rem', '0'),
|
||||||
|
'_padding:tablet_portrait' => av_sp('4rem', '0', '3rem', '0'),
|
||||||
|
'_padding:mobile_landscape' => av_sp('3rem', '0', '2.5rem', '0'),
|
||||||
|
'_padding:mobile_portrait' => av_sp('2.5rem', '0', '2rem', '0'),
|
||||||
|
'_background' => ['color' => av_color('var(--color-bg-default)')],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $oct, 'name' => 'container', 'parent' => $sid, 'children' => [$nrr, $img],
|
||||||
|
'settings' => [
|
||||||
|
'_direction' => 'column',
|
||||||
|
'_widthMax' => '1280px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
||||||
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
||||||
|
'_alignItems' => 'flex-start',
|
||||||
|
'_rowGap' => '2rem',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $nrr, 'name' => 'container', 'parent' => $oct, 'children' => [$eyb, $hId, $met],
|
||||||
|
'settings' => [
|
||||||
|
'_direction' => 'column',
|
||||||
|
'_widthMax' => '800px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
||||||
|
'_padding' => av_sp('0', '0', '0', '0'),
|
||||||
|
'_rowGap' => '1rem',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = av_eyebrow($eyb, $nrr, 'Insights');
|
||||||
|
// Heading uses dynamic data : {post_title}
|
||||||
|
$els[] = [
|
||||||
|
'id' => $hId, 'name' => 'heading', 'parent' => $nrr, 'children' => [],
|
||||||
|
'settings' => [
|
||||||
|
'tag' => 'h1',
|
||||||
|
'text' => '{post_title}',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
// Meta : {post_date} · {post_author}
|
||||||
|
$els[] = av_text($met, $nrr, '{post_date} \u{B7} {post_author}', 'var(--color-text-tertiary)', '0.875rem', '1.5');
|
||||||
|
|
||||||
|
// Featured image (Bricks image element with dynamic data)
|
||||||
|
$els[] = [
|
||||||
|
'id' => $img, 'name' => 'image', 'parent' => $oct, 'children' => [],
|
||||||
|
'settings' => [
|
||||||
|
'image' => ['useDynamicData' => '{featured_image}'],
|
||||||
|
'_widthMax' => '1280px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_aspectRatio' => '16/9',
|
||||||
|
'_objectFit' => 'cover',
|
||||||
|
'_border' => ['radius' => av_sp('4px', '4px', '4px', '4px')],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Content section
|
||||||
|
$csec = 'spostcc';
|
||||||
|
$coct = 'spostco';
|
||||||
|
$cnrr = 'spostcn';
|
||||||
|
$cpc = 'spostpc'; // post-content element
|
||||||
|
|
||||||
|
$els[] = [
|
||||||
|
'id' => $csec, 'name' => 'section', 'parent' => 0, 'children' => [$coct],
|
||||||
|
'settings' => array_merge(av_section_padding_responsive('3rem', '5rem'), [
|
||||||
|
'_background' => ['color' => av_color('var(--color-bg-default)')],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $coct, 'name' => 'container', 'parent' => $csec, 'children' => [$cnrr],
|
||||||
|
'settings' => [
|
||||||
|
'_direction' => 'column',
|
||||||
|
'_widthMax' => '1280px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_padding' => av_sp('0', '1.5rem', '0', '1.5rem'),
|
||||||
|
'_padding:mobile_landscape' => av_sp('0', '1rem', '0', '1rem'),
|
||||||
|
'_alignItems' => 'flex-start',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $cnrr, 'name' => 'container', 'parent' => $coct, 'children' => [$cpc],
|
||||||
|
'settings' => [
|
||||||
|
'_direction' => 'column',
|
||||||
|
'_widthMax' => '800px',
|
||||||
|
'_width' => '100%',
|
||||||
|
'_margin' => av_sp('0', 'auto', '0', '0'),
|
||||||
|
'_padding' => av_sp('0', '0', '0', '0'),
|
||||||
|
'_rowGap' => '1rem',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$els[] = [
|
||||||
|
'id' => $cpc, 'name' => 'post-content', 'parent' => $cnrr, 'children' => [],
|
||||||
|
'settings' => [
|
||||||
|
// Rendered by Bricks's post-content element : the post body
|
||||||
|
'_typography' => [
|
||||||
|
'color' => av_color('var(--color-text-secondary)'),
|
||||||
|
'font-size' => '1.0625rem',
|
||||||
|
'line-height' => '1.65',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Closer CTA (re-uses shared)
|
||||||
|
$els = array_merge($els, av_build_closer_cta('spost'));
|
||||||
|
|
||||||
|
return $els;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------
|
||||||
|
SEED POSTS : 3 sample articles to populate the blog hub
|
||||||
|
----------------------------------------------------------------- */
|
||||||
|
function av_get_insight_seed_posts() {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'slug' => 'the-10-to-15-gap',
|
||||||
|
'title' => 'The 10-to-15 gap : why 90 cartridges per year became a fiction',
|
||||||
|
'date' => '2026-04-22 10:00:00',
|
||||||
|
'excerpt' => 'French regulation sets a clear floor for police marksmanship training. Reality, almost everywhere, falls short. Here is what the gap looks like, why it persists, and what closing it operationally actually means.',
|
||||||
|
'content' => "<p>The number is officially 90. Ninety cartridges per officer per year — the regulatory minimum for French police marksmanship training. Officers know the number. Trainers know the number. The administrative records track the number. And the median actual figure, when you measure it across regional units, sits at <strong>10 to 15 cartridges per officer per year</strong>.</p>
|
||||||
|
|
||||||
|
<p>This is not a story of negligence. The infrastructure is saturated. Live ranges are booked weeks ahead. Instructor time is finite. Ammunition budgets are constrained. The gap exists because the system cannot scale the way training needs scale.</p>
|
||||||
|
|
||||||
|
<h2>Why the gap matters operationally</h2>
|
||||||
|
|
||||||
|
<p>Marksmanship is not the goal. The goal is decision-making under stress. Marksmanship is the substrate that makes everything else possible. When repetition collapses by an order of magnitude, the substrate erodes. We see it in the data : tactical errors under stress rise. Time-to-first-shot under contact extends. Target identification accuracy degrades.</p>
|
||||||
|
|
||||||
|
<p>The fix cannot come from building more ranges. It has to come from changing what counts as a training repetition.</p>
|
||||||
|
|
||||||
|
<h2>What VR changes</h2>
|
||||||
|
|
||||||
|
<p>VR training does not replace live fire. It changes the economics of the next repetition. A live-fire drill costs ammunition, range time, instructor time, decontamination. A VR drill costs none of those, and produces measurable data the live one cannot.</p>
|
||||||
|
|
||||||
|
<p>If you can do twenty VR drills for the cost of one live one, the median annual repetition figure becomes 200 to 300 — not 10 to 15. And the question of whether the regulatory floor of 90 is even ambitious enough starts to look different.</p>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'simulators-for-entertainment-vs-training',
|
||||||
|
'title' => 'Why simulators built for entertainment break in tactical training',
|
||||||
|
'date' => '2026-03-18 14:30:00',
|
||||||
|
'excerpt' => 'Most VR shooting simulators were engineered with one priority : visual realism. For tactical training, that is the wrong priority. Here is what we learned building the platform the other way around.',
|
||||||
|
'content' => "<p>If you have spent any time evaluating VR training products, you will have noticed a pattern. The marketing leads with the visuals. The screenshots are cinematic. The demos open with photoreal cityscapes and dramatic lighting.</p>
|
||||||
|
|
||||||
|
<p>This is what entertainment-first engineering looks like. And it is the wrong way to build a training tool.</p>
|
||||||
|
|
||||||
|
<h2>Three reasons it breaks</h2>
|
||||||
|
|
||||||
|
<p><strong>1. Ballistics are an afterthought.</strong> If the engine was built to render a beautiful frame at thirty meters, it was probably not built to compute trajectory, drag, and material penetration accurately at three meters. We have seen products where bullets stopped at the texture surface, where ricochets ignored angle of impact, where through-cover engagements were not modeled at all.</p>
|
||||||
|
|
||||||
|
<p><strong>2. AI is scripted.</strong> When civilians are on rails — walk this path, react this way to this trigger — operators learn the script, not the discipline. The drill becomes pattern recognition. Real engagements do not have patterns.</p>
|
||||||
|
|
||||||
|
<p><strong>3. The instructor sees what the trainee sees.</strong> A training tool needs to give the supervisor a vantage point the operator does not have. Walk through walls. Switch into any trainee's view. Pause without breaking immersion. Most entertainment-derived products show the instructor a duplicate of one trainee's HUD.</p>
|
||||||
|
|
||||||
|
<h2>What it costs to invert the priorities</h2>
|
||||||
|
|
||||||
|
<p>Building physics-first, AI-behavior-second, photoreal-third is harder, slower, and visibly less impressive in a demo. We accept that. The trade-off is a training tool that actually trains.</p>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'sovereign-by-default-gdpr-native',
|
||||||
|
'title' => 'Sovereign by default : what GDPR-native actually means for security forces',
|
||||||
|
'date' => '2026-02-05 09:15:00',
|
||||||
|
'excerpt' => "There is a difference between a product that complies with GDPR and one designed for it from the start. For training data \u{2014} sensitive by definition \u{2014} the difference matters.",
|
||||||
|
'content' => "<p>GDPR compliance is a checkbox on many vendor pitch decks. It usually means : we have a privacy policy, we let users export their data, we encrypt connections to our cloud.</p>
|
||||||
|
|
||||||
|
<p>For training data generated by security forces, this is not enough.</p>
|
||||||
|
|
||||||
|
<h2>What 'native' means</h2>
|
||||||
|
|
||||||
|
<p>A platform is GDPR-native when the architecture decisions were made under privacy constraints from day one. Not bolted on later. Concretely :</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Data stays local.</strong> No telemetry leaves the customer's network. No analytics fired at our servers. No remote logging.</li>
|
||||||
|
<li><strong>Identity stays local.</strong> Authentication runs against the customer's directory (Active Directory, LDAP), not a vendor cloud account.</li>
|
||||||
|
<li><strong>Updates are offline-capable.</strong> Air-gapped facilities can run the platform indefinitely without ever needing to phone home.</li>
|
||||||
|
<li><strong>No US Cloud Act exposure.</strong> We do not use US-controlled cloud services for storage or compute. European or sovereign-allied only.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Why this matters for tactical units</h2>
|
||||||
|
|
||||||
|
<p>Training data for a tactical unit includes : the geometry of their training facility, their operational doctrine, their reaction times, their identification accuracy, their squad composition. This is intelligence-grade material.</p>
|
||||||
|
|
||||||
|
<p>A vendor cloud that hosts this data, however well-encrypted, is a single point of compromise — and a single point of jurisdictional exposure. The only safe handling is to make sure the data never has to leave.</p>
|
||||||
|
|
||||||
|
<p>That is not a feature we added. It is the constraint we designed under.</p>",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user