Back to Shiftfield
SOURCE / PINNED RELEASE

Made of little things.

Shiftfield

Release
5a170c505f6b…
Author-recorded commit
a2e23e0171aa…
License
LICENSE
Author’s source reference
nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd

Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/upgrades.ts
/* Minesweeper DX — Phase 3 Workshop: upgrade definitions (pure data).
 *
 * Player ruling (2026-10-24): the Workshop sells Radar, Seeker, Shield and
 * Economy. Radar and Seeker START LOCKED (level 0) — the player must buy the
 * first level to ever use the ability. Anchor Radius is REMOVED from the game
 * (a flag only ever locks the tile it stands on).
 * Player ruling (2026-10-27, stabilizer_shop_ruling): Stabilizer joins the
 * Workshop — it ALSO starts LOCKED; Lv1 buys the ability itself.
 *
 *  radar      0 locked | 1 = 3×3 | 2 = 5×5 minus the 4 corners | 3 = full 5×5
 *             (expensive: information is the strongest offense in Minesweeper)
 *  seeker     0 locked | N = flags N mines per use (max 3)
 *  shield     0 = no shields | N = SHIELDS A RUN STARTS WITH (max 5). Not a
 *             Flow purchase: charges are consumed by mine hits and never
 *             recharge mid-run (ruling 2026-10-27, shop_flow_rebalance)
 *  stabilizer 0 locked | N = shift-freeze turns 3/5/7/10 (4 levels; cooldown
 *             stays 10 turns at EVERY level, so Lv4's 10-turn hold spans the
 *             cooldown: recast every cooldown, the Shifter never fires)
 *  economy    +25% run-scrap per level, 4 levels, max +100%
 *  flow_cap   Flow storage ceiling. Base cap is 100 (ruling 2026-10-27,
 *             flow_cap_ruling); each level raises the cap by 100:
 *             Lv1 200 · Lv2 300 · Lv3 400 · Lv4 500 (max). Not an ability:
 *             it never appears on the ability bar, it just clamps Flow gains.
 *
 * Cost table (playtest-tunable; the curve front-loads Radar per the ruling):
 *  radar      150 / 400 / 900
 *  seeker      60 / 200 / 500
 *  shield      50 / 120 / 250 / 430 / 700
 *  stabilizer  80 / 200 / 450 / 900
 *  economy     50 / 150 / 300 / 500
 *  flow_cap    50 / 125 / 275 / 500 (deliberately under Radar: a bigger
 *             buffer helps but grants no information; rebalance via playtest)
 *
 * Modifier pattern (master spec §5.1): consumers compute
 * `Base_Value * (1 + modifier)` — e.g. economy scrap multiplier
 * `1 + 0.25 × level`.
 *
 * Shop copy (ruling 2026-10-28, shop_layout_ruling): describe() strings are
 * MICRO-FORMAT — `current effect · next: delta`, maxed lines end in `MAX`,
 * locked lines start with `Locked`. Keep them short (shop cards are narrow);
 * the browser suite + upgrade.test pin the format. Cards are grouped by the
 * §3.2 archetypes via SHOP_GROUPS: Intel / Survival / Economy.
 */

export type UpgradeId = 'radar' | 'seeker' | 'shield' | 'stabilizer' | 'economy' | 'flow_cap';

export type UpgradeLevels = Record<UpgradeId, number>;

export interface UpgradeDef {
  readonly id: UpgradeId;
  readonly name: string;
  readonly maxLevel: number;
  /** §3.2 archetype grouping shown as a Shop section header. */
  readonly group: 'Intel' | 'Survival' | 'Economy';
  /** Scrap cost to buy level index+1 (length === maxLevel). */
  readonly costs: readonly number[];
  /** Micro-format effect line AT the given owned level (Shop cards).
   *  Format (ruling 2026-10-28, shop_layout_ruling):
   *    level 0      → 'Locked — Lv.1 <first unlock>'
   *    0<level<max  → '<effect at level> · next: <delta>'
   *    level === max→ '<effect at level> · MAX' */
  readonly describe: (level: number) => string;
}

export const UPGRADE_DEFS: Record<UpgradeId, UpgradeDef> = {
  radar: {
    id: 'radar',
    name: 'Radar Array',
    maxLevel: 3,
    group: 'Intel',
    costs: [150, 400, 900],
    describe: (level) =>
      level === 0
        ? 'Locked — Lv.1 unlocks a 3×3 safe sweep'
        : level === 1
          ? 'Sweep 3×3 (9 tiles) · next: 5×5 circle'
          : level === 2
            ? 'Sweep 5×5 circle (21 tiles) · next: full 5×5'
            : 'Sweep full 5×5 (25 tiles) · MAX',
  },
  seeker: {
    id: 'seeker',
    name: 'Seeker Drone',
    maxLevel: 3,
    group: 'Intel',
    costs: [60, 200, 500],
    describe: (level) =>
      level === 0
        ? 'Locked — Lv.1 flags 1 mine per use'
        : level < 3
          ? `Flags ${level} mine${level > 1 ? 's' : ''} per use · next: ${level + 1}`
          : 'Flags 3 mines per use · MAX',
  },
  shield: {
    id: 'shield',
    name: 'Shield Generator',
    maxLevel: 5,
    group: 'Survival',
    costs: [50, 120, 250, 430, 700],
    describe: (level) =>
      level === 0
        ? 'Locked — Lv.1 starts every run with 1 shield'
        : level < 5
          ? `Run starts with ${level} shield${level > 1 ? 's' : ''} · next: ${level + 1}`
          : 'Run starts with 5 shields · MAX',
  },
  stabilizer: {
    id: 'stabilizer',
    name: 'Stabilizer Array',
    maxLevel: 4,
    group: 'Survival',
    costs: [80, 200, 450, 900],
    describe: (level) =>
      level === 0
        ? 'Locked — Lv.1 freezes the Shifter 3 turns'
        : level < 4
          ? `Freezes the Shifter ${stabilizerDurationFor(level)}T · next: ${stabilizerDurationFor(level + 1)}T`
          : 'Freezes the Shifter 10T · MAX — never fires',
  },
  economy: {
    id: 'economy',
    name: 'Data Ledger',
    maxLevel: 4,
    group: 'Economy',
    costs: [50, 150, 300, 500],
    describe: (level) =>
      level === 0
        ? 'Locked — Lv.1 grants +25% run scrap'
        : `Run scrap +${level * 25}%` +
          (level < 4 ? ` · next: +${(level + 1) * 25}%` : ' · MAX'),
  },
  flow_cap: {
    id: 'flow_cap',
    name: 'Flow Capacitor',
    maxLevel: 4,
    group: 'Economy',
    costs: [50, 125, 275, 500],
    describe: (level) =>
      level === 0
        ? 'Locked — Lv.1 raises Flow cap 100 → 200'
        : level < 4
          ? `Flow ceiling ${flowCapFor(level)} · next: ${flowCapFor(level + 1)}`
          : 'Flow ceiling 500 · MAX',
  },
};

export const UPGRADE_ORDER: readonly UpgradeId[] = [
  'radar', 'seeker', 'shield', 'stabilizer', 'economy', 'flow_cap',
];

/** Shop section order and the lines within each (ruling 2026-10-28,
 *  shop_layout_ruling). Drives group headers + intra-group row order. */
export const SHOP_GROUPS: ReadonlyArray<{
  group: UpgradeDef['group'];
  ids: readonly UpgradeId[];
}> = [
  { group: 'Intel', ids: ['radar', 'seeker'] },
  { group: 'Survival', ids: ['shield', 'stabilizer'] },
  { group: 'Economy', ids: ['economy', 'flow_cap'] },
];

/** A player who has bought nothing: all abilities LOCKED (rulings
 * 2026-10-24 + 2026-10-27: Radar/Seeker/Shield, now Stabilizer too);
 * flow_cap 0 = the plain 100-Flow ceiling (ruling 2026-10-27). */
export const ZERO_UPGRADES: UpgradeLevels = {
  radar: 0, seeker: 0, shield: 0, stabilizer: 0, economy: 0, flow_cap: 0,
};

/** All upgrades at max — headless tests construct engines fully-equipped. */
export const MAX_UPGRADES: UpgradeLevels = {
  radar: 3, seeker: 3, shield: 5, stabilizer: 4, economy: 4, flow_cap: 4,
};

/** Shifter-freeze length per Stabilizer Workshop level (ruling 2026-10-27):
 * Lv1 3 · Lv2 5 · Lv3 7 · Lv4 10. The ability cooldown stays 10 turns at
 * EVERY level, so at Lv4 the 10-turn hold exactly spans the cooldown and a
 * player who recasts on cooldown freezes the Shifter indefinitely. */
export const STABILIZER_DURATIONS = [3, 5, 7, 10] as const;

/** Turns a Stabilizer cast holds the shift countdown at the given level
 * (0 = locked). Exported so tests assert against the symbol, not literals. */
export function stabilizerDurationFor(level: number): number {
  return level >= 1 && level <= STABILIZER_DURATIONS.length
    ? STABILIZER_DURATIONS[level - 1]
    : 0;
}

/** Economy multiplier on run scrap (spec §5.1 pattern): 1 + 0.25 × level. */
export function economyMultiplier(levels: UpgradeLevels): number {
  return 1 + 0.25 * levels.economy;
}

/** Flow ceiling (ruling 2026-10-27, flow_cap_ruling): base 100, +100 per
 *  flow_cap Workshop level, hard max 500 at Lv4. Exported as symbols so
 *  tests assert against them, never a duplicated literal. */
export const BASE_FLOW_CAP = 100;
export const FLOW_CAP_PER_LEVEL = 100;
export const MAX_FLOW_CAP =
  BASE_FLOW_CAP + FLOW_CAP_PER_LEVEL * UPGRADE_DEFS.flow_cap.maxLevel; // 500

/** Flow ceiling at a flow_cap level. Missing/invalid levels (legacy saves,
 *  partial QA-hook fixtures) fall back to the LOCKED default → base cap. */
export function flowCapFor(level: number | undefined): number {
  const n = typeof level === 'number' && Number.isFinite(level)
    ? Math.max(0, Math.min(UPGRADE_DEFS.flow_cap.maxLevel, level))
    : 0;
  return BASE_FLOW_CAP + FLOW_CAP_PER_LEVEL * n;
}

/** Radar offsets for a level as [dx, dy] pairs relative to the target.
 * Lv1: 3×3. Lv2: Chebyshev ≤2 EXCLUDING the four corners (circle look).
 * Lv3: full 5×5. Lv0: empty (ability locked). */
export function radarOffsets(level: number): ReadonlyArray<readonly [number, number]> {
  const offsets: Array<readonly [number, number]> = [];
  const radius = level >= 1 ? (level === 1 ? 1 : 2) : 0;
  const dropCorners = level === 2;
  if (level <= 0) return offsets; // locked: no sweep, not even the center
  for (let dx = -radius; dx <= radius; dx++) {
    for (let dy = -radius; dy <= radius; dy++) {
      if (dropCorners && Math.abs(dx) === 2 && Math.abs(dy) === 2) continue;
      offsets.push([dx, dy]);
    }
  }
  return offsets;
}

/** Cost to buy the NEXT level of an upgrade; null when maxed. */
export function nextCost(id: UpgradeId, level: number): number | null {
  const def = UPGRADE_DEFS[id];
  return level >= def.maxLevel ? null : def.costs[level];
}