SOURCE / PINNED RELEASE
Made of little things.
City Breaker
- Release
- 94d2b8664b10…
- Author-recorded commit
- df23103cbcc1…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-cf8b858653e
Archive hash verified: eeeed725668c1ece…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { AIRCRAFT_TYPES, CONFIG, MUNITION_IDS, type AircraftType, type InterceptorType, type MunitionId } from './config';
import { DISTRICTS, SAM_SITES, type RouteId, type SamKind } from './map';
export interface Airframe {
id: number;
type: AircraftType;
tail: string;
crew: string;
xp: number;
damage: number; // 0 … 1
readyAt: number; // absolute hour
status: 'ready' | 'maint' | 'lost';
sorties: number;
}
export interface Captive {
crew: string;
type: AircraftType;
hour: number;
resolved: 'pending' | 'rescued' | 'abandoned' | 'failed';
}
export interface Decoy {
id: string;
x: number;
y: number;
revealed: boolean;
destroyed: boolean;
seen: boolean; // photographed or struck — shown on the map, indistinguishable unless revealed
}
export interface DistrictState {
id: string;
integrity: number;
fire: number;
ldbHours: number;
dispersal: number; // share of output moved out after rebuild
firebreak: number;
powered: boolean;
intelHour: number; // last verified recon
knownIntegrity: number;
everDestroyed: boolean;
decoys: Decoy[];
hitsKg: number;
}
export interface SamSiteState {
id: string;
kind: SamKind;
x: number;
y: number;
knownX: number;
knownY: number;
knownHour: number;
radar: number; // 0 … 1
launchers: number;
isDecoy: boolean;
revealed: boolean;
disabledUntil: number;
missiles: number;
}
export interface FuelFarm {
id: string;
x: number;
y: number;
fuel: number; // days of reserve stored
known: boolean;
destroyed: boolean;
}
export interface RaidProfile {
hour: number;
route: RouteId;
alt: 'low' | 'med' | 'high';
night: boolean;
munitions: string[];
targetKinds: string[];
guidedSuccess: boolean;
usedJamming: boolean;
}
export interface NewsItem {
hour: number;
text: string;
kind: 'enemy' | 'hq' | 'intel' | 'moral';
}
export interface CityState {
population: number;
workers: number;
casualties: number;
injured: number;
evacuated: number;
evacuationTriggered: boolean;
morale: number;
resolve: number;
pools: { fireBrigades: number; railBattalions: number; generators: number; labor: number };
aidCrews: number;
stock: { coal: number; fuel: number; samReloads: number; steel: number };
batteryHours: number;
pontoon: number;
interceptors: Record<InterceptorType, number>;
adaptPoints: number;
adaptLevels: Record<string, number>;
adaptPending: { key: string; readyTick: number }[];
history: RaidProfile[];
destructionLog: { hour: number; frac: number }[];
fuelFarms: FuelFarm[];
lastAidHour: number;
shieldsActive: boolean;
}
export interface Stats {
missions: number;
sorties: number;
tonnage: number;
tonnageByKind: Record<string, number>;
decoyTonnage: number;
aircraftLost: number;
crewsKilled: number;
crewsCaptured: number;
crewsRescued: number;
civiliansKilled: number;
militaryDamage: number;
civilianDamage: number;
raidHours: number[];
firestorms: number;
samsKilled: number;
interceptorsKilled: number;
roeViolations: number;
reconSorties: number;
playerSorties: number;
playerBombsOnTarget: number;
}
export interface EndResult {
outcome: 'win' | 'partial' | 'lose';
reason: string;
hour: number;
}
export interface GameState {
version: number;
seed: number;
rng: number;
hour: number;
tick: number;
fleet: Airframe[];
nextAirframeId: number;
munitions: Record<MunitionId, number>;
pres: number;
drones: number;
fuel: number;
political: number;
linebacker: boolean;
lastRaidHour: number;
standDownUntil: number;
districts: Record<string, DistrictState>;
sams: SamSiteState[];
city: CityState;
news: NewsItem[];
stats: Stats;
outputHistory: { hour: number; output: number }[];
criticalDownHours: number;
lastReplacementHour: number;
outputAtLastReplacement: number;
foxbatDelivered: boolean;
captives: Captive[];
raidHeat: { x: number; y: number; kg: number; hour: number }[];
lastWeeklyHour: number;
ended: EndResult | null;
tipsSeen?: string[]; // first-time coaching already shown
}
const SURNAMES = [
'Volkov', 'Morozov', 'Lebedev', 'Kozlov', 'Novikov', 'Sokolov', 'Popov', 'Orlov', 'Zaitsev', 'Pavlov',
'Semenov', 'Golubev', 'Vinogradov', 'Bogdanov', 'Vorobyov', 'Fedorov', 'Mikhailov', 'Belyaev', 'Tarasov', 'Belov',
'Komarov', 'Kiselev', 'Makarov', 'Andreev', 'Kovalev', 'Ilyin', 'Gusev', 'Titov', 'Kuzmin', 'Kudryavtsev',
'Baranov', 'Kulikov', 'Alekseev', 'Stepanov', 'Yakovlev', 'Sorokin', 'Sergeev', 'Romanov', 'Zakharov', 'Borisov',
'Gerasimov', 'Ponomarev', 'Grigoryev', 'Lazarev', 'Medvedev', 'Ershov', 'Nikitin', 'Sobolev', 'Ryabov', 'Polyakov',
];
const RANKS = ['Lt.', 'Sr. Lt.', 'Capt.', 'Maj.'];
export function rand(s: GameState): number {
// mulberry32
s.rng = (s.rng + 0x6d2b79f5) | 0;
let t = s.rng;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function crewName(s: GameState): string {
return `${RANKS[Math.floor(rand(s) * RANKS.length)]} ${SURNAMES[Math.floor(rand(s) * SURNAMES.length)]}`;
}
export function addAirframe(s: GameState, type: AircraftType, xp: number): Airframe {
const a: Airframe = {
id: s.nextAirframeId++,
type,
tail: `${type.replace(/[^0-9A-Z]/g, '').slice(-3)}-${String(10 + Math.floor(rand(s) * 89))}`,
crew: crewName(s),
xp,
damage: 0,
readyAt: s.hour,
status: 'ready',
sorties: 0,
};
s.fleet.push(a);
return a;
}
export function newCampaign(seed: number): GameState {
const s: GameState = {
version: 1,
seed,
rng: seed,
hour: (CONFIG.campaign.startDay - 1) * 24 + CONFIG.campaign.startHour,
tick: 0,
fleet: [],
nextAirframeId: 1,
munitions: Object.fromEntries(MUNITION_IDS.map((m) => [m, CONFIG.munitions[m].stock])) as Record<MunitionId, number>,
pres: CONFIG.pres.stock,
drones: CONFIG.drones.stock,
fuel: CONFIG.fuelTons.stock,
political: CONFIG.campaign.startPolitical,
linebacker: false,
lastRaidHour: -999,
standDownUntil: 0,
districts: {},
sams: [],
city: {
population: CONFIG.city.population,
workers: CONFIG.city.workers,
casualties: 0,
injured: 0,
evacuated: 0,
evacuationTriggered: false,
morale: 0.7,
resolve: 0.2,
pools: { ...CONFIG.city.pools },
aidCrews: 0,
stock: { ...CONFIG.city.stockpileDays },
batteryHours: CONFIG.city.batteryReserveHours,
pontoon: 0,
interceptors: { 'F-104G': CONFIG.interceptors['F-104G'].count, 'F-4': CONFIG.interceptors['F-4'].count, Lightning: CONFIG.interceptors.Lightning.count },
adaptPoints: 4,
adaptLevels: Object.fromEntries(Object.keys(CONFIG.adaptation.costs).map((k) => [k, 0])),
adaptPending: [],
history: [],
destructionLog: [],
fuelFarms: [],
lastAidHour: 0,
shieldsActive: false,
},
news: [],
stats: {
missions: 0, sorties: 0, tonnage: 0, tonnageByKind: {}, decoyTonnage: 0, aircraftLost: 0, crewsKilled: 0,
crewsCaptured: 0, crewsRescued: 0, civiliansKilled: 0, militaryDamage: 0, civilianDamage: 0, raidHours: [],
firestorms: 0, samsKilled: 0, interceptorsKilled: 0, roeViolations: 0, reconSorties: 0, playerSorties: 0, playerBombsOnTarget: 0,
},
outputHistory: [],
criticalDownHours: 0,
lastReplacementHour: 0,
outputAtLastReplacement: 1,
foxbatDelivered: false,
captives: [],
raidHeat: [],
lastWeeklyHour: 0,
ended: null,
};
for (const t of AIRCRAFT_TYPES) {
for (let i = 0; i < CONFIG.aircraft[t].count; i++) {
addAirframe(s, t, CONFIG.crew.startXp + (rand(s) - 0.5) * 0.3);
}
}
for (const d of DISTRICTS) {
s.districts[d.id] = {
id: d.id, integrity: 1, fire: 0, ldbHours: 0, dispersal: 0, firebreak: 0, powered: true,
intelHour: s.hour, knownIntegrity: 1, everDestroyed: false, decoys: [], hitsKg: 0,
};
}
for (const site of SAM_SITES) {
const spec = CONFIG.sam[site.kind];
s.sams.push({
id: site.id, kind: site.kind, x: site.x, y: site.y, knownX: site.x, knownY: site.y, knownHour: s.hour,
radar: 1, launchers: 1, isDecoy: false, revealed: false, disabledUntil: 0, missiles: spec.stock,
});
}
return s;
}
export function dayOf(hour: number): number {
return Math.floor(hour / 24) + 1;
}
export function hourOfDay(hour: number): number {
return ((hour % 24) + 24) % 24;
}
export function fmtTime(hour: number): string {
return `D${dayOf(hour)} ${String(Math.floor(hourOfDay(hour))).padStart(2, '0')}:00`;
}
export function isNight(hourAbs: number): boolean {
const h = hourOfDay(hourAbs);
return h < 5 || h >= 20;
}
export function lightLevel(hourAbs: number): number {
// 0 = night, 1 = full day; dusk/dawn ramps
const h = hourOfDay(hourAbs);
if (h >= 7 && h < 18) return 1;
if (h >= 5 && h < 7) return (h - 5) / 2;
if (h >= 18 && h < 20) return 1 - (h - 18) / 2;
return 0;
}
