SOURCE / PINNED RELEASE
Made of little things.
Squad Table
- Release
- d36201f8ed4f…
- Author-recorded commit
- 7b9bb681d6ba…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d
Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Original maps and scenarios. Terrain is shown for reference; the players
// decide what it means under the rules they are playing.
import type { NationId } from './catalog.js';
import { hexId, parseHex, type Hex } from './hex.js';
export type Terrain = 'open' | 'woods' | 'brush' | 'grain' | 'orchard' | 'wood-bldg' | 'stone-bldg' | 'marsh' | 'water';
export const TERRAIN_NAMES: Record<Terrain, string> = {
open: 'Open ground',
woods: 'Woods',
brush: 'Brush',
grain: 'Grain',
orchard: 'Orchard',
'wood-bldg': 'Wooden building',
'stone-bldg': 'Stone building',
marsh: 'Marsh',
water: 'Water',
};
const CODES: Record<string, Terrain> = {
'.': 'open',
w: 'woods',
h: 'brush',
g: 'grain',
o: 'orchard',
b: 'wood-bldg',
s: 'stone-bldg',
m: 'marsh',
'~': 'water',
};
export type MapCell = { id: string; hex: Hex; terrain: Terrain; level: number; road: boolean };
export type Hexside = { a: string; b: string; kind: 'hedge' | 'wall' };
export type GameMap = {
id: string;
name: string;
cols: number;
rows: number;
cells: Map<string, MapCell>;
roads: string[][];
hexsides: Hexside[];
};
type MapSource = {
id: string;
name: string;
terrain: string[];
elevation: string[];
roads: string[][];
hedges?: [string, string][];
walls?: [string, string][];
};
function buildMap(src: MapSource): GameMap {
const rows = src.terrain.length;
const cols = src.terrain[0].length;
const cells = new Map<string, MapCell>();
const roadHexes = new Set(src.roads.flat());
for (let r = 1; r <= rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hex = { c, r };
const id = hexId(hex);
const road = roadHexes.has(id);
const code = src.terrain[r - 1][c];
cells.set(id, {
id,
hex,
terrain: road ? 'open' : (CODES[code] ?? 'open'),
level: Number(src.elevation[r - 1]?.[c] ?? 0) || 0,
road,
});
}
}
const hexsides: Hexside[] = [
...(src.hedges ?? []).map(([a, b]) => ({ a, b, kind: 'hedge' as const })),
...(src.walls ?? []).map(([a, b]) => ({ a, b, kind: 'wall' as const })),
];
return { id: src.id, name: src.name, cols, rows, cells, roads: src.roads, hexsides };
}
const row = (r: number, from: string, to: string): string[] => {
const a = parseHex(`${from}${r}`)!;
const b = parseHex(`${to}${r}`)!;
const out: string[] = [];
for (let c = a.c; c <= b.c; c += 1) out.push(hexId({ c, r }));
return out;
};
const column = (col: string, from: number, to: number): string[] =>
Array.from({ length: to - from + 1 }, (_, i) => `${col}${from + i}`);
export const MAPS: Record<string, GameMap> = {
millbrook: buildMap({
id: 'millbrook',
name: 'Millbrook crossroads',
terrain: [
'www..ggg.....oo...ww..',
'wwh..ggg..b..oo..www..',
'wh.........b.....ww..h',
'.....h...bs.sb........',
'......................',
'...gg....bs..sb....oo.',
'...gg.....b..b....h...',
'ww.......h......gg...w',
'www..mm~~.......gg..ww',
'wwww.mm~~~..h......www',
],
elevation: [
'0000000000000000122221',
'0000000000000000122221',
'0000000000000000011211',
'0000000000000000001110',
'0000000000000000000000',
'0000000000000000000000',
'0000000000000000000000',
'0000000000000000000000',
'0000000000000000000000',
'0000000000000000000000',
],
roads: [row(5, 'A', 'V'), column('L', 1, 10)],
hedges: [
['C6', 'D6'],
['C7', 'D7'],
['D5', 'D6'],
['E6', 'F6'],
['P8', 'Q8'],
['P9', 'Q9'],
],
walls: [
['J4', 'J5'],
['K4', 'K5'],
['M4', 'M5'],
['N4', 'N5'],
['S5', 'S6'],
['T5', 'T6'],
],
}),
};
export type Placement = { k: string; at: string; count?: number };
export type ScenarioSide = {
nat: NationId;
name: string;
setup: string; // free-text setup instructions
entry?: 'west' | 'east' | 'north' | 'south'; // map edge for units entering from off-board
units: Placement[]; // `at` may be a hex id or "pool" (off-board, ready to enter)
};
export type Scenario = {
id: string;
name: string;
map: string;
turns: number;
brief: string;
victory: string;
first: 'a' | 'b'; // side that moves first
sides: { a: ScenarioSide; b: ScenarioSide };
};
export const SCENARIOS: Scenario[] = [
{
id: 'millbrook-dawn',
name: 'Dawn at Millbrook',
map: 'millbrook',
turns: 6,
brief:
'A reinforced American company pushes east along the valley road. A thin German screen holds the stone houses at the crossroads.',
victory: 'Americans win at game end if they hold more stone-building hexes than the Germans. Otherwise the Germans win.',
first: 'a',
sides: {
a: {
nat: 'us',
name: 'American',
setup: 'Enter on turn 1 along the west edge (column A).',
entry: 'west',
units: [
{ k: 'us-666', at: 'pool', count: 6 },
{ k: 'us-747', at: 'pool', count: 2 },
{ k: 'us-ldr91', at: 'pool' },
{ k: 'us-ldr80', at: 'pool' },
{ k: 'us-ldr70', at: 'pool' },
{ k: 'us-mmg', at: 'pool' },
{ k: 'us-lmg', at: 'pool', count: 2 },
{ k: 'us-baz', at: 'pool' },
],
},
b: {
nat: 'ge',
name: 'German',
setup: 'Set up first, east of column H. Units start where shown; adjust before turn 1 by agreement.',
units: [
{ k: 'ge-467', at: 'K4' },
{ k: 'ge-467', at: 'N6' },
{ k: 'ge-467', at: 'K6' },
{ k: 'ge-447', at: 'M4' },
{ k: 'ge-447', at: 'L7' },
{ k: 'ge-ldr81', at: 'K6' },
{ k: 'ge-ldr80', at: 'N6' },
{ k: 'ge-mmg', at: 'K6' },
{ k: 'ge-lmg', at: 'K4' },
{ k: 'ge-pf', at: 'N6' },
{ k: 'mk-conceal', at: 'M4' },
{ k: 'mk-conceal', at: 'L7' },
],
},
},
},
{
id: 'hill-stand',
name: 'Stand on the High Ground',
map: 'millbrook',
turns: 5,
brief: 'Russian guards storm the wooded hill east of Millbrook, held by German riflemen with machine-gun support.',
victory: 'Russians win if they control every level-2 hex at game end.',
first: 'a',
sides: {
a: {
nat: 'ru',
name: 'Russian',
setup: 'Set up second, west of column P.',
units: [
{ k: 'ru-628', at: 'O2', count: 2 },
{ k: 'ru-628', at: 'O4', count: 2 },
{ k: 'ru-447', at: 'N6', count: 2 },
{ k: 'ru-447', at: 'N3', count: 2 },
{ k: 'ru-ldr91', at: 'O2' },
{ k: 'ru-ldr80', at: 'N6' },
{ k: 'ru-lmg', at: 'O4' },
{ k: 'ru-mtr', at: 'N3' },
],
},
b: {
nat: 'ge',
name: 'German',
setup: 'Set up first on or east of column R.',
units: [
{ k: 'ge-467', at: 'S2' },
{ k: 'ge-467', at: 'T3' },
{ k: 'ge-447', at: 'U2' },
{ k: 'ge-ldr91', at: 'S2' },
{ k: 'ge-hmg', at: 'S2' },
{ k: 'ge-lmg', at: 'T3' },
{ k: 'mk-fox', at: 'S2' },
{ k: 'mk-fox', at: 'T3' },
],
},
},
},
{
id: 'open-table',
name: 'Open table',
map: 'millbrook',
turns: 8,
brief: 'An empty map. Add counters from the tray to set up your own scenario or try a situation from your rulebook.',
victory: 'Agree on victory conditions before you start.',
first: 'a',
sides: {
a: { nat: 'us', name: 'American', setup: 'Free setup.', units: [] },
b: { nat: 'ge', name: 'German', setup: 'Free setup.', units: [] },
},
},
];
export const SCENARIO_BY_ID = new Map(SCENARIOS.map((s) => [s.id, s]));
export const PHASES = [
'Rally',
'Prep Fire',
'Movement',
'Defensive Fire',
'Advancing Fire',
'Rout',
'Advance',
'Close Combat',
] as const;
