Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ runs keep their existing pause layout and rules.
shared schema makes campaign progress portable for future import/export tooling but
does not automatically sync files between devices.

- The story archive presents the existing sector discoveries as one signal chain, with a recovery meter and the next unrecovered lead. It reuses the campaign discovery IDs and does not add a separate save format.
- The web galaxy map uses the existing sector positions to draw connected routes, distinguish locked and charted systems, and show the selected sector's objective without duplicating campaign progression or save data.

### Realism pass
Expand Down
66 changes: 47 additions & 19 deletions web/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,22 @@ const STORY_ENTRIES = [
{ id: "void-origin", sectorId: "void-crown", title: "The Signal's Origin", classification: "FRONTIER ARCHIVE",
teaser: "The source lies beyond the mapped systems.", body: "At the edge of the Void Crown, the signal resolves into a navigational pattern. The four sectors are not separate destinations; together they form a route. The beacon was never a warning. It was a map." },
];

// Summarize the existing discovery chain without adding a second save/progression system.
function storySignalState(discoveries = []) {
const known = new Set(Array.isArray(discoveries) ? discoveries : []);
const recovered = STORY_ENTRIES.filter(entry => known.has(entry.id));
const next = STORY_ENTRIES.find(entry => !known.has(entry.id)) || null;
return {
recoveredCount: recovered.length,
total: STORY_ENTRIES.length,
recoveredIds: recovered.map(entry => entry.id),
nextEntryId: next ? next.id : null,
nextTitle: next ? next.title : "",
decoded: next === null,
};
}

const EXPLORATION_OBJECTIVES = [
{ id: "sol-signal", sectorId: "sol-frontier", title: "Trace the Beacon", detail: "Triangulate the repeating signal across the orbital debris.", reward: 20, color: "#78c8ff" },
{ id: "sol-wreckage", sectorId: "sol-frontier", title: "Survey the Wreckage", detail: "Scan a derelict relay drifting beyond the inner ring.", reward: 25, color: "#a7b8d8" },
Expand Down Expand Up @@ -3110,36 +3126,48 @@ class Game {
c.fillStyle = "rgba(2, 5, 16, 0.98)"; c.fillRect(0, 0, W, H);
for (let i = 0; i < 64; i++) {
const x = (i * 173 + 23) % W, y = (i * 127 + 61) % H;
circle(c, x, y, i % 8 === 0 ? 2 : 1, i % 3 === 0 ? "rgba(180,140,255,0.62)" : "rgba(210,225,255,0.30)");
}
const found = this.save.campaign.discoveries.length;
text(c, "FRONTIER ARCHIVE", W / 2, 62, 34, "#e4d9ff", true);
text(c, `RECOVERED INTEL / ${found} OF ${STORY_ENTRIES.length} RECORDS`, W / 2, 101, 14, "#9e91c7", true);
const alpha = 0.20 + 0.22 * (0.5 + 0.5 * Math.sin(this.time * 0.6 + i));
circle(c, x, y, i % 8 === 0 ? 2 : 1,
i % 3 === 0 ? `rgba(180,140,255,${alpha})` : `rgba(210,225,255,${alpha})`);
}
const signal = storySignalState(this.save.campaign.discoveries);
text(c, "FRONTIER ARCHIVE", W / 2, 58, 34, "#e4d9ff", true);
text(c, `RECOVERED INTEL / ${signal.recoveredCount} OF ${signal.total} RECORDS`, W / 2, 99, 14, "#9e91c7", true);

panel(c, 38, 119, W - 76, 62, "rgba(24, 17, 48, 0.88)", "rgba(180,140,255,0.42)", 12);
text(c, "SIGNAL CHAIN", 54, 142, 11, "#c7b5ff", true, "left");
text(c, signal.decoded ? "ROUTE DECODED" : `NEXT LEAD / ${signal.nextTitle}`,
W - 54, 142, 12, signal.decoded ? "#8dffce" : "#e4d9ff", true, "right");
c.fillStyle = "rgba(100,85,140,0.45)"; c.fillRect(54, 154, W - 108, 7);
c.fillStyle = signal.decoded ? "#8dffce" : "#b48cff";
c.fillRect(54, 154, (W - 108) * (signal.recoveredCount / Math.max(1, signal.total)), 7);

STORY_ENTRIES.forEach((entry, i) => {
const y = 132 + i * 218, unlocked = this.storyDiscovered(entry);
const y = 196 + i * 195, unlocked = this.storyDiscovered(entry);
const selected = this.storySelectedId === entry.id;
panel(c, 38, y, W - 76, 194,
panel(c, 38, y, W - 76, 170,
selected ? "rgba(38, 25, 66, 0.88)" : "rgba(9, 12, 28, 0.88)",
selected ? "rgba(180, 140, 255, 0.78)" : "rgba(150, 140, 190, 0.22)", 14);
text(c, unlocked ? entry.classification : "ENCRYPTED RECORD", 60, y + 27, 12,
unlocked ? "#bba0ff" : "#6b6682", true, "left");
text(c, unlocked ? entry.title : `RECORD ${String(i + 1).padStart(2, "0")} / LOCKED`, 60, y + 57, 20,
unlocked ? "#f0eaff" : "#8a849d", true, "left");
text(c, unlocked ? entry.classification : `FRAGMENT ${String(i + 1).padStart(2, "0")} / ENCRYPTED`,
60, y + 25, 11, unlocked ? "#bba0ff" : "#6b6682", true, "left");
text(c, unlocked ? entry.title : `RECORD ${String(i + 1).padStart(2, "0")} / LOCKED`,
60, y + 52, 19, unlocked ? "#f0eaff" : "#8a849d", true, "left");
const lines = unlocked ? entry.body : entry.teaser;
const words = lines.split(" ");
let line = "", lineY = y + 88, count = 0;
let line = "", lineY = y + 81, count = 0;
for (const word of words) {
const nextLine = line ? line + " " + word : word;
if (nextLine.length > 67 && line) {
text(c, line, 60, lineY, 14, unlocked ? "#c5c8dc" : "#77748a", false, "left");
line = word; lineY += 23; count++;
text(c, line, 60, lineY, 13, unlocked ? "#c5c8dc" : "#77748a", false, "left");
line = word; lineY += 21; count++;
if (count >= 3) break;
} else line = nextLine;
}
if (line && count < 3) text(c, line, 60, lineY, 14, unlocked ? "#c5c8dc" : "#77748a", false, "left");
text(c, unlocked ? "RECOVERED" : `COMPLETE ${CAMPAIGN_SECTORS[i].name} TO DECRYPT`, W - 58, y + 169, 11,
unlocked ? "#8dffce" : "#6e778b", true, "right");
this.buttons.push({ x: 38, y, w: W - 76, h: 194, fn: () => { this.storySelectedId = entry.id; } });
if (line && count < 3) text(c, line, 60, lineY, 13, unlocked ? "#c5c8dc" : "#77748a", false, "left");
text(c, unlocked ? (i < STORY_ENTRIES.length - 1 ? "SIGNAL POINTS TO THE NEXT SECTOR" : "ALL SIGNAL FRAGMENTS CONNECTED")
: `COMPLETE ${CAMPAIGN_SECTORS[i].name} TO DECRYPT`,
W - 58, y + 151, 10, unlocked ? "#8dffce" : "#6e778b", true, "right");
this.buttons.push({ x: 38, y, w: W - 76, h: 170, fn: () => { this.storySelectedId = entry.id; } });
});
this.smallButton(c, "BACK", 52, 1024, 270, 60, "#b48cff", () => this.closeStoryArchive());
this.smallButton(c, this.storyReturnState === "campaign" ? "BACK TO STAR MAP" : "OPEN STAR MAP",
Expand Down Expand Up @@ -3539,5 +3567,5 @@ if (typeof module !== "undefined" && module.exports)
module.exports = { Game, MockAdProvider, AdMobProvider, createAdProvider, Save, Sfx, Social, Leaderboard,
CAMPAIGN_SECTORS, STORY_ENTRIES, EXPLORATION_OBJECTIVES, freshCampaign, sanitizeCampaign, DemoIapProvider, DisabledIapProvider, CdvIapProvider, createIapProvider, GEM_PACKS, CONTINUE_BASE_COST,
SHIPS, CONTRACTS, MISSION_POOL, BIOMES, METEOR_TYPES, SHOP_ITEMS,
dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, W, H };
dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, W, H };
else window.addEventListener("load", boot);
2 changes: 1 addition & 1 deletion web/sw.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Service worker: caches the game so it works offline once installed.
// Bump CACHE whenever the game files change. Navigation is network-first so a
// deployed update is picked up as soon as the player is online.
const CACHE = "orbit-runner-v19";
const CACHE = "orbit-runner-v20";
const FILES = ["./", "./index.html", "./config.js", "./game.js", "./manifest.json", "./icon-192.png", "./icon-512.png"];

self.addEventListener("install", e => {
Expand Down
17 changes: 16 additions & 1 deletion web/test/game.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { Game, MockAdProvider, AdMobProvider, Save, Social, Leaderboard, MISSION_POOL, CONTRACTS,
BIOMES, SHOP_ITEMS, GEM_PACKS, DemoIapProvider, dateKey, weekKey, CAMPAIGN_SECTORS, STORY_ENTRIES, EXPLORATION_OBJECTIVES, freshCampaign,
sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint } = require("../game.js");
sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState } = require("../game.js");

function localStore() {
const store = {};
Expand Down Expand Up @@ -583,6 +583,21 @@ test("daily and ranked runs do not advance campaign sectors", () => {
});


test("story signal chain reports the next lead and completes only when all known records are recovered", () => {
assert.deepEqual(storySignalState([]), {
recoveredCount: 0, total: STORY_ENTRIES.length, recoveredIds: [],
nextEntryId: STORY_ENTRIES[0].id, nextTitle: STORY_ENTRIES[0].title, decoded: false,
});
const partial = storySignalState(["beacon-awake", "unknown-record"]);
assert.equal(partial.recoveredCount, 1);
assert.deepEqual(partial.recoveredIds, ["beacon-awake"]);
assert.equal(partial.nextEntryId, "ember-expedition");
const complete = storySignalState(STORY_ENTRIES.map(entry => entry.id));
assert.equal(complete.recoveredCount, STORY_ENTRIES.length);
assert.equal(complete.nextEntryId, null);
assert.equal(complete.decoded, true);
});

test("story archive migrates completed sectors and rejects invalid discoveries", () => {
const migrated = sanitizeCampaign({
version: 1,
Expand Down
Loading