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.
/* 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];
}
