Back to City Breaker
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.

src/game/state.ts
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;
}