diff --git a/assets/videos/vega/audience-human-angry.mp4 b/assets/videos/vega/audience-human-angry.mp4 new file mode 100644 index 0000000..f7e02a7 Binary files /dev/null and b/assets/videos/vega/audience-human-angry.mp4 differ diff --git a/assets/videos/vega/audience-human-happy.mp4 b/assets/videos/vega/audience-human-happy.mp4 new file mode 100644 index 0000000..b061743 Binary files /dev/null and b/assets/videos/vega/audience-human-happy.mp4 differ diff --git a/assets/videos/vega/audience-human-neutral.mp4 b/assets/videos/vega/audience-human-neutral.mp4 new file mode 100644 index 0000000..a38c866 Binary files /dev/null and b/assets/videos/vega/audience-human-neutral.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 6748c58..082a73d 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -128,17 +128,53 @@ "radiated": { "key": "vega-colony-radiated", "path": "assets/videos/vega/colony-radiated.mp4" } }, - "_audienceVideosReadme": "Audience-screen MOOD clips for the full-screen diplomacy interface — one per diplomacy-capable species x {angry, neutral, happy}, 3:2 (e.g. 1200x800 or 1080x720), muted, looping. Lithox has no entry at all: canNegotiate() is permanently false for them (traits.diplomacy <= -100), so the Audience screen never opens for that species and a clip would never play. A species entirely absent (null) or a mood missing inside a recorded species falls back to that species' portraitVideos clip (256x256, letterboxed to 3:2), then to portraitStills — same three-tier fallback convention as makeSpeciesPortrait, so the roster fills one species (or one mood) at a time with no code change. Naming: assets/videos/vega/audience--.mp4.", + "_audienceVideosReadme": "Audience-screen MOOD clips for the full-screen diplomacy interface — one per diplomacy-capable species x {angry, neutral, happy}, muted, looping. Recorded at 896x576 (1.5556:1, close to but not exactly the 3:2 the VegaAudience.js frame is cut to — the clip is scaled by WIDTH only, never setDisplaySize, so it sits slightly short of the frame's full height rather than being stretched). Lithox has no entry at all: canNegotiate() is permanently false for them (traits.diplomacy <= -100), so the Audience screen never opens for that species and a clip would never play. A species entirely absent (null) or a mood missing inside a recorded species falls back to that species' portraitVideos clip (256x256, letterboxed to 3:2), then to portraitStills — same three-tier fallback convention as makeSpeciesPortrait, so the roster fills one species (or one mood) at a time with no code change. Naming: assets/videos/vega/audience--.mp4.", "audienceVideos": { - "human": null, - "kestrelli": null, - "ursaal": null, - "umbrix": null, - "kkrix": null, - "mekhan": null, - "rrashaa": null, - "cerebrai": null, - "ssakar": null + "human": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "kestrelli": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "ursaal": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "umbrix": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "kkrix": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "mekhan": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "rrashaa": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "cerebrai": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + }, + "ssakar": { + "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, + "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, + "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + } }, "sheets": { diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 709599d..ef64e11 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -85,6 +85,7 @@ { "id": "scout", "name": "Scout", "role": "recon", "baseCost": 18, "baseHp": 10, "space": 0, "speedBonus": 2, "rangeBonus": 3, "frame": 0, "desc": "Unarmed, fast and long-legged. Peels back the dark." }, { "id": "colonyship", "name": "Colony Ship", "role": "colony", "baseCost": 100, "baseHp": 18, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 1, "desc": "Carries a founding population. Consumed on arrival." }, { "id": "transport", "name": "Troop Transport","role": "troops","baseCost": 40, "baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "troops": 4, "desc": "Four divisions of marines for boarding and invasion." }, + { "id": "poptransport","name": "Colony Transport","role": "colony","baseCost": 30,"baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "desc": "Ferries colonists between worlds you already hold. Dispatched directly from a colony's Send Population order, never built from the queue — shares the Troop Transport's hull picture (frame 2, the ships sheet is full at 8 columns)." }, { "id": "frigate", "name": "Frigate", "role": "warship", "baseCost": 35, "baseHp": 18, "space": 6, "speedBonus": 1, "rangeBonus": 1, "frame": 3, "desc": "Cheap escort. Screens the line and hunts scouts." }, { "id": "destroyer", "name": "Destroyer", "role": "warship", "baseCost": 90, "baseHp": 40, "space": 14, "speedBonus": 1, "rangeBonus": 0, "frame": 4, "desc": "The workhorse combatant of any mid-game fleet." }, { "id": "cruiser", "name": "Cruiser", "role": "warship", "baseCost": 240, "baseHp": 100, "space": 32, "speedBonus": 0, "rangeBonus": 0, "frame": 5, "desc": "Heavy line ship. Expensive enough to hurt when it dies." }, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 4430b98..dd922f9 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -890,6 +890,8 @@ export default class MasterOfVegaGame extends Phaser.Scene { // A held peace/alliance offer — not urgent enough to interrupt with a // popup, but discoverable next time Diplomacy is opened. this.log(`The ${name(ev.empire)} seek an audience.`); + } else if (ev.type === 'populationDelivered') { + this.log(`${ev.amount.toFixed(1)} population arrived at ${star?.name}.`); } } return notable; diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js index 1e9b08d..0f35f94 100644 --- a/src/games/mastervega/VegaAudience.js +++ b/src/games/mastervega/VegaAudience.js @@ -147,52 +147,89 @@ function buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h) { return container; } -function buildMoodVisual(scene, rules, art, speciesId, mood, x, y, w, h, parent) { - let obj; - if (hasAudienceVideo(scene, speciesId, mood)) { - obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood)); - obj.setMute(true); - obj.setLoop(true); - const fit = () => obj.setScale(w / sourceWidth(obj, w)); - fit(); - obj.on('created', fit); - obj.on('playing', fit); - obj.play(true); - } else { - obj = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h); - } - parent.add(obj); - return obj; -} - // `parent` is the container these objects are added to (so they inherit the // screen's depth tier and are torn down for free when it is destroyed — // Container.destroy() recurses into its children, same as VegaColonyIntro's // video: "destroys the video with it, which is what stops its decoder"). -function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent) { +// +// Unlike the colony-founding clip, these carry NO audio at all — muted for +// their whole life, never unmuted — so there is no applyAudio()-style +// re-assertion to worry about. They DO share the colony clip's "plays once, +// holds the last frame, replay badge" contract: `onEndedChanged(ended)` +// fires whenever the CURRENTLY shown video's ended state should be reflected +// by the caller's replay badge — on first build, on every show() (so +// swapping back to an already-finished mood re-arms the badge immediately), +// on the video's own 'complete' event, and on an explicit replay() call. +function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEndedChanged) { const anyClip = MOODS.some((m) => hasAudienceVideo(scene, speciesId, m)); if (!anyClip) { // Nothing to visually swap, but `current` still has to track the real // mood — otherwise afterAction()'s "did the tier change" check compares // against a value that never updates and fires the glow pulse on every - // single action regardless of whether the mood actually moved. + // single action regardless of whether the mood actually moved. The + // fallback (a species portrait, video or still) is not a "clip" in the + // play-once sense, so there is nothing to replay. const shared = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h); parent.add(shared); let current = null; - return { show(mood) { current = mood; }, get current() { return current; } }; + return { + show(mood) { current = mood; }, get current() { return current; }, replay() {}, + }; } - const built = {}; + const built = {}; // mood -> { obj, ended } let current = null; + + function build(mood) { + const obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood)); + obj.setMute(true); + // Plays once and holds the last frame — an ended HTMLVideoElement stops + // on its final frame and the texture keeps it, so ending IS the still. + // Same contract as VegaColonyIntro's clip, minus the audio. + obj.setLoop(false); + const fit = () => obj.setScale(w / sourceWidth(obj, w)); + fit(); + obj.on('created', fit); + obj.on('playing', fit); + const entry = { obj, ended: false }; + // Not `once` — a replayed clip fires 'complete' again every time it ends. + obj.on('complete', () => { + entry.ended = true; + if (current === mood) onEndedChanged?.(true); + }); + obj.play(false); + parent.add(obj); + built[mood] = entry; + return entry; + } + return { show(mood) { if (current === mood) return; - if (built[current]) { built[current].setVisible(false); built[current].pause?.(); } - if (!built[mood]) built[mood] = buildMoodVisual(scene, rules, art, speciesId, mood, x, y, w, h, parent); - built[mood].setVisible(true); - built[mood].play?.(true); + if (built[current]) { built[current].obj.setVisible(false); built[current].obj.pause(); } + let entry = built[mood]; + if (!entry) entry = build(mood); + else { + entry.obj.setVisible(true); + // Only resume playback if it never finished — an ended video's + // `.play()`/`.resume()` seeks back to 0 on some browsers, which + // would silently replay it just for being swapped back to. + if (!entry.ended) entry.obj.resume(); + } current = mood; + onEndedChanged?.(entry.ended); }, get current() { return current; }, + replay() { + const entry = built[current]; + if (!entry) return; + entry.ended = false; + onEndedChanged?.(false); + // A genuine replay: Phaser's completeHandler cleared `_playCalled` when + // the clip ended, and an ended element seeks back to 0 of its own + // accord when play() is called on it (verified against VegaColonyIntro's + // identical replay mechanism). + entry.obj.play(false); + }, }; } @@ -216,6 +253,10 @@ const VIDEO_Y = 190; const VIDEO_W = 1080; const VIDEO_H = 720; // 3:2 +/** The replay badge, inset from the video's bottom-right corner. */ +const REPLAY_R = 26; +const REPLAY_PAD = 18; + const CHAT_X = VIDEO_X + VIDEO_W + 60; const CHAT_W = GAME_WIDTH - CHAT_X - 70; const CHAT_Y = VIDEO_Y; @@ -283,7 +324,36 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo frame.add(scene.add.rectangle(VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16, PANEL, 0.55) .setOrigin(0, 0).setStrokeStyle(1.5, ACCENT, 0.5)); frame.add(cornerTicks(scene, VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16, speciesColor, 26)); - const moodVisual = createMoodVisual(scene, rules, art, speciesId, videoCx, videoCy, VIDEO_W, VIDEO_H, frame); + + // --- replay badge: armed whenever the currently-shown mood clip has + // played through once. Built once, over the whole picture (clicking + // anywhere on it replays), same shape as VegaColonyIntro's — minus audio, + // since these clips are muted for their whole life. + const replayHit = scene.add.rectangle(videoCx, videoCy, VIDEO_W, VIDEO_H, 0xffffff, 0.001) + .setInteractive({ useHandCursor: true }).setVisible(false); + const replayBadge = scene.add.container( + videoCx + VIDEO_W / 2 - REPLAY_PAD - REPLAY_R, + videoCy + VIDEO_H / 2 - REPLAY_PAD - REPLAY_R, + ).setVisible(false); + replayBadge.add(scene.add.circle(0, 0, REPLAY_R, PANEL, 0.82).setStrokeStyle(2, ACCENT, 0.95)); + replayBadge.add(scene.add.triangle(3, 0, 0, 0, 0, 22, 19, 11, 0xe8f4ff)); + function armReplay(on) { + replayHit.setVisible(on); + replayBadge.setVisible(on).setScale(1); + if (!on) return; + replayBadge.setAlpha(0); + scene.tweens.add({ targets: replayBadge, alpha: 1, duration: 260, ease: 'Cubic.easeOut' }); + } + replayHit.on('pointerover', () => replayBadge.setScale(1.1)); + replayHit.on('pointerout', () => replayBadge.setScale(1)); + replayHit.on('pointerup', () => moodVisual.replay()); + + const moodVisual = createMoodVisual(scene, rules, art, speciesId, videoCx, videoCy, VIDEO_W, VIDEO_H, frame, + armReplay); + // Added after the video(s) so the hit zone and badge sit on top of them — + // a Container renders its children in insertion order and ignores depth. + frame.add(replayHit); + frame.add(replayBadge); const glow = scene.add.graphics().setAlpha(0); frame.add(glow); scene.tweens.add({ targets: frame, alpha: 1, scale: 1, duration: 220, ease: 'Cubic.easeOut' }); diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index e8f2908..70f9418 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -42,6 +42,7 @@ import { CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign, + empireColonies, maxSendablePopulation, sendPopulation, etaTo, } from './VegaLogic.js'; const ACCENT = 0x6fc4ff; @@ -240,6 +241,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { if (closed) return; closed = true; detail?.close(); + closeTransportPicker(); dropScrollers(); // Out of the tree first, or root.destroy() takes it and pool.destroy() // then runs against a corpse. @@ -365,7 +367,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { // So the two lists below give way rather than spilling past the frame, the // same way VegaSidePanel's `room()` works. The status block and the sliders // are never allowed to be the thing that gets dropped. - const buttonY = PANEL_Y + PANEL_H - 76; + const buttonY = PANEL_Y + PANEL_H - 76 - 56; const room = (px) => y + px < buttonY - 12; // --- what is being built @@ -415,6 +417,12 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { } y = buttonY; + const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22, + 'Send Population', openTransportPicker, { width: w, height: 44, fontSize: 18 })); + if (maxSendablePopulation(colony) <= 0 || empireColonies(state, colony.empireIdx).length < 2) { + sendBtn.setEnabled(false); + } + y += 56; add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24, flyOpen ? 'Close Build Queue' : 'Build Queue', toggleFlyout, { width: w, height: 48, fontSize: 20 })); @@ -614,6 +622,8 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { const turns = (cost) => (rate > 0 ? `${Math.max(1, Math.ceil(cost / rate))} turns` : 'no budget'); for (const hull of rules.hullList) { + // Dispatched directly by the Send Population order, never queued. + if (hull.id === 'poptransport') continue; const d = empireDesign(rules, state, colony.empireIdx, hull.id); entry({ hullId: hull.id }, d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff', @@ -665,6 +675,136 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { r.on('pointerup', (p) => { if (!guard || guard.contains(p)) fn(); }); } + // ------------------------------------------------- population transport + // + // MOO1-style: a self-contained overlay opened from the panel — pick one of + // your other colonies, dial an amount up to what the source can spare, and + // send it. Dispatches through Logic.sendPopulation(), which builds the + // 'poptransport' fleet already in transit; delivery on arrival is automatic + // (VegaLogic.js's moveFleets), so there is no separate "unload" step here. + + const TW = 640; + const TH = 520; + const TX = (GAME_WIDTH - TW) / 2; + const TY = (GAME_HEIGHT - TH) / 2; + + let transportLayer = null; + let listCol = null; // tracked so its wheel listener is unhooked on every rebuild, not just on close + + function closeTransportPicker() { + listCol?.destroy(); + listCol = null; + transportLayer?.destroy(); + transportLayer = null; + } + + function openTransportPicker() { + if (transportLayer) return; + const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id); + let dest = others[0] ?? null; + let amount = 1; + + transportLayer = scene.add.container(0, 0); + root.add(transportLayer); + + const renderTransport = () => { + listCol?.destroy(); + listCol = null; + transportLayer.removeAll(true); + transportLayer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.6) + .setOrigin(0, 0).setInteractive()); + frame(scene, transportLayer, TX, TY, TW, TH, 0.97); + + const x = TX + PAD; + const w = TW - PAD * 2; + let y = TY + 24; + + transportLayer.add(scene.add.text(x, y, 'SEND POPULATION', { + fontFamily: FONT, fontSize: '24px', color: '#cfe8ff', + })); + transportLayer.add(new Button(scene, TX + TW - 38, y + 14, '✕', closeTransportPicker, + { width: 40, height: 36, fontSize: 18, variant: 'ghost' })); + y += 46; + + transportLayer.add(scene.add.text(x, y, + `From ${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1} — ${colony.pop.toFixed(1)} population`, { + fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', + })); + y += 34; + + if (!others.length) { + transportLayer.add(scene.add.text(x, y, 'No other colonies to send population to.', { + fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', + })); + return; + } + + transportLayer.add(scene.add.text(x, y, 'DESTINATION', { + fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', + })); + y += 20; + + const listH = 220; + listCol = scrollColumn(scene, transportLayer, x, y, w, listH); + let ly = 0; + others.forEach((c) => { + const dStar = state.galaxy.stars[c.starIdx]; + const selected = dest?.id === c.id; + const row = scene.add.rectangle(0, ly, w, 42, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5) + .setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2) + .setInteractive({ useHandCursor: true }); + row.on('pointerup', (p) => { if (listCol.contains(p)) { dest = c; renderTransport(); } }); + listCol.content.add(row); + const eta = etaTo(rules, state, { starIdx: colony.starIdx, empireIdx: colony.empireIdx }, + c.starIdx, [{ hullId: 'poptransport', mark: 1, count: 1 }]); + listCol.content.add(scene.add.text(12, ly + 6, `${dStar.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`, { + fontFamily: FONT, fontSize: '16px', color: '#e8f4ff', + })); + listCol.content.add(scene.add.text(12, ly + 25, + `${c.pop.toFixed(1)} population · ${Number.isFinite(eta) ? `${eta} turn${eta === 1 ? '' : 's'}` : 'unreachable'}`, { + fontFamily: FONT, fontSize: '13px', color: '#8fa8c0', + })); + ly += 48; + }); + listCol.contentHeight = ly; + y += listH + 20; + + const cap = maxSendablePopulation(colony); + amount = Phaser.Math.Clamp(amount, 0, cap); + + transportLayer.add(scene.add.text(x, y, 'AMOUNT TO SEND', { + fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', + })); + y += 24; + transportLayer.add(scene.add.text(x, y, amount.toFixed(1), { + fontFamily: FONT, fontSize: '30px', color: '#ffd88a', + })); + const setAmount = (v) => { amount = Phaser.Math.Clamp(v, 0, cap); renderTransport(); }; + pusher(transportLayer, x + 110, y + 15, '−', () => setAmount(amount - 1), amount > 0); + pusher(transportLayer, x + 142, y + 15, '+', () => setAmount(amount + 1), amount < cap); + transportLayer.add(new Button(scene, x + 200, y + 15, 'MAX', () => setAmount(cap), + { width: 66, height: 32, fontSize: 14, variant: 'ghost' })); + transportLayer.add(scene.add.text(x, y + 40, `${cap.toFixed(1)} available to send`, { + fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', + })); + y += 76; + + const canSend = !!dest && amount > 0; + const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', () => { + if (!dest) return; + if (sendPopulation(rules, state, colony.empireIdx, colony.starIdx, dest.starIdx, amount)) { + closeTransportPicker(); + buildPanel(); + onChanged?.(); + } + }, { width: w, height: 44, fontSize: 18 }); + if (!canSend) sendBtn.setEnabled(false); + transportLayer.add(sendBtn); + }; + + renderTransport(); + } + buildPanel(); return { root, close }; } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index aa4ce33..c2484b8 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -1029,8 +1029,10 @@ function moveFleets(rules, state, e) { pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn }); } checkContactAt(rules, state, f.starIdx); + deliverPopulation(rules, state, f); } } + cleanFleets(state); } // Empires meet by sharing a system — a ship or colony of one sitting where a @@ -1254,6 +1256,77 @@ export function colonize(rules, state, e, starIdx, orbit) { return true; } +// -------------------------------------------------------------------------- +// Population transport — MOO1's "send N population from one of your colonies +// to another." Unlike colonize()'s colony ship, this never sits in a build +// queue: the 'poptransport' hull (data/mastervega-rules.json) is dispatched +// directly, in transit already, the instant the order is given, carrying +// whatever amount was chosen as a payload on its own ship stack rather than a +// fixed per-hull capacity. It rides the same movement/fuel-range/combat +// machinery as any other fleet, so an unescorted transport crossing hostile +// space is exactly as vulnerable as a colony ship would be. + +// A colony can never be fully emptied this way — same floor colony growth +// itself never goes below (see the `Math.max(0.5, ...)` in processColony). +const MIN_POP_LEFT_BEHIND = 0.5; + +export function maxSendablePopulation(colony) { + return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND); +} + +export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) { + if (fromStarIdx === toStarIdx) return false; + const colony = colonyAt(state, fromStarIdx); + if (!colony || colony.empireIdx !== e) return false; + const dest = colonyAt(state, toStarIdx); + if (!dest || dest.empireIdx !== e) return false; + const send = Math.min(amount, maxSendablePopulation(colony)); + if (send <= 0) return false; + + // Built directly in transit — see the module comment above for why this + // does not go through addFleet()/sendFleet() (which would first have to + // land in an idle fleet at the source, risking a merge with whatever is + // garrisoned there before it could be split back out). + const probe = { empireIdx: e, starIdx: fromStarIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] }; + if (!canSendFleet(rules, state, probe, toStarIdx)) return false; + + state.fleets.push({ + id: state.nextFleetId += 1, + empireIdx: e, + starIdx: -1, + fromStar: fromStarIdx, + toStar: toStarIdx, + progress: 0, + total: parsecs(state.galaxy, fromStarIdx, toStarIdx), + ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }], + }); + colony.pop -= send; + pushEvent(state, { + type: 'populationSent', empire: e, starIdx: fromStarIdx, toStarIdx, amount: send, turn: state.turn, + }); + return true; +} + +// Called from moveFleets the instant a poptransport fleet arrives. Delivers +// to a still-friendly colony there and consumes the stack; if the star no +// longer has a colony of ours (captured, or the fleet was mis-sent), the +// payload just sits inert in orbit rather than being silently discarded, in +// case the player wants to redirect it by hand later. +function deliverPopulation(rules, state, f) { + const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0); + if (!stack) return; + const colony = colonyAt(state, f.starIdx); + if (!colony || colony.empireIdx !== f.empireIdx) return; + const maxPop = colonyMaxPop(rules, state, colony); + colony.pop = Math.min(maxPop, colony.pop + stack.popPayload); + pushEvent(state, { + type: 'populationDelivered', empire: f.empireIdx, starIdx: f.starIdx, + colonyId: colony.id, amount: stack.popPayload, turn: state.turn, + }); + stack.count = 0; + stack.popPayload = 0; +} + // Orbital superiority at a system: our combat power there exceeds theirs. // Bombardment and invasion both require it. export function holdsOrbit(rules, state, e, starIdx, defenderIdx) { diff --git a/src/games/mastervega/VegaShipMedia.js b/src/games/mastervega/VegaShipMedia.js index d64a4f2..2ee65db 100644 --- a/src/games/mastervega/VegaShipMedia.js +++ b/src/games/mastervega/VegaShipMedia.js @@ -29,8 +29,17 @@ const SHIP_FRAME_PX = 192; export const shipVideoKey = (speciesId, hullId) => `vega-ship-${speciesId}-${hullId}`; +// The Colony Transport ('poptransport') shares the Troop Transport's static +// picture (same sheet frame — see data/mastervega-rules.json, the ships sheet +// is full at 8 columns) but is deliberately given the COLONY SHIP's commander +// clip instead: thematically the same kind of settlement run, and it means +// every species with a colony-ship video already has one for this hull too, +// with zero new video files. `shipVideoKey` itself stays a pure naming +// function — the alias is applied only where a video is actually looked up. +const VIDEO_HULL_ALIAS = { poptransport: 'colonyship' }; + export function hasShipVideo(scene, speciesId, hullId) { - return !!scene.cache.video?.exists(shipVideoKey(speciesId, hullId)); + return !!scene.cache.video?.exists(shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? hullId)); } /** @@ -61,7 +70,7 @@ export function makeCommanderPortrait( if (!hasShipVideo(scene, speciesId, hullId)) { return makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size); } - const v = scene.add.video(x, y, shipVideoKey(speciesId, hullId)); + const v = scene.add.video(x, y, shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? hullId)); v.setMute(true); v.setLoop(true); // Scale from the known source size rather than setDisplaySize — see diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index 2239a13..875df2f 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -31,7 +31,10 @@ export const NOTABLE_TYPES = new Set([ // // `colonised` stays classified here even though it is no longer notable: the // ticker log runs every event past isRelevantToHuman() too. -const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone']); +const PERSONAL_TYPES = new Set([ + 'discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone', + 'populationSent', 'populationDelivered', +]); export function isRelevantToHuman(ev, me) { if (PERSONAL_TYPES.has(ev.type)) return ev.empire === me; diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index e0dbca5..90abcd0 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -46,7 +46,7 @@ import { CHAT } from '../src/games/mastervega/VegaChat.js'; import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.js'; // Ship media is addressed here and nowhere else, so the key convention is // checkable without a canvas. -import { shipVideoKey } from '../src/games/mastervega/VegaShipMedia.js'; +import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js'; // Dependency-free, so what the game room eagerly pulls is checkable here. import { resolveGameAssets } from '../src/data/assetManifest.js'; @@ -311,6 +311,27 @@ section('2. Procedural art'); console.log(` (${shipClips}/${RULES.speciesList.length * RULES.hullList.length} ` + 'ship commander clips recorded; the rest fall back to the species portrait)'); + // poptransport's commander clip is deliberately aliased to the colony + // ship's (VegaShipMedia.js) — same picture as the Troop Transport, but the + // "settlement run" video. hasShipVideo() takes a scene as a plain + // duck-typed parameter (no Phaser import in that module), so the alias is + // checkable here with a stub rather than needing a browser. + { + const stubScene = (cachedKeys) => ({ cache: { video: { exists: (k) => cachedKeys.has(k) } } }); + const withColonyClip = stubScene(new Set([shipVideoKey('human', 'colonyship')])); + check('poptransport resolves to a video when the species has a colony-ship clip', + hasShipVideo(withColonyClip, 'human', 'poptransport')); + const withoutAnyClip = stubScene(new Set()); + check('poptransport reports no video when the colony-ship clip is absent too', + !hasShipVideo(withoutAnyClip, 'human', 'poptransport')); + // A clip filed under poptransport's OWN key must NOT be what satisfies + // this — the alias means only the colony ship's key is ever consulted, + // and one under poptransport's own name is never expected to exist. + const onlyOwnKey = stubScene(new Set([shipVideoKey('human', 'poptransport')])); + check('the alias looks at the colony ship\'s key, not poptransport\'s own', + !hasShipVideo(onlyOwnKey, 'human', 'poptransport')); + } + // Audience-screen mood clips: species -> {angry, neutral, happy}. Lithox // (diplomacy-incapable) must have NO entry at all — canNegotiate() is // permanently false for them, so the Audience screen never opens for that @@ -883,6 +904,138 @@ section('4b. Fleet orders and detachments'); } } +// --------------------------------------------------------------------------- +section('4c. Population transport'); +// --------------------------------------------------------------------------- +{ + // MOO1's "send N population from one colony to another" — dispatched + // directly as an already-in-transit 'poptransport' fleet (VegaLogic.js: + // sendPopulation), never built from a colony's queue. + const hull = RULES.hulls.poptransport; + check('the poptransport hull exists', !!hull); + check('poptransport shares the Troop Transport\'s sheet frame (no new art)', + hull?.frame === RULES.hulls.transport.frame); + check('poptransport is unarmed (space 0, matching its role)', hull?.space === 0); + check('poptransport carries no troops', !(hull?.troops > 0)); + + // 'large' + only two species maximises the odds of an unclaimed, in-range + // star existing near the human's homeworld to found a second colony on — + // same defensive style as section 6b's colonize() setup, which checks + // rather than assumes a settleable target exists. + function mkTwoColonyGame(seed) { + const st = Logic.createGame(RULES, { + sizeId: 'large', shapeId: 'cluster', seed, difficultyId: 'normal', + speciesIds: ['human', 'lithox'], humanIndex: 0, + }); + st.rules = RULES; + const home = st.galaxy.homeIdx[0]; + const target = Object.keys(Logic.reachableStars(RULES, st, 0)).map(Number) + .find((i) => i !== home && i !== st.galaxy.homeIdx[1]); + if (target === undefined) return { st, home, target: -1, second: null }; + const second = Logic.foundColony(RULES, st, 0, target, 0, 5); + return { st, home, target, second }; + } + + { + const { st, home, target, second } = mkTwoColonyGame(4141); + check('the test galaxy offers a second site to found on', target >= 0); + if (target >= 0) { + const source = Logic.colonyAt(st, home); + const before = source.pop; + const cap = Logic.maxSendablePopulation(source); + check('maxSendablePopulation leaves the floor behind', cap === before - 0.5); + + const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap); + check('sendPopulation succeeds for a reachable, owned destination', ok); + check('the source colony loses exactly what was sent', Math.abs(source.pop - 0.5) < 1e-9, + `${source.pop}`); + + // Already in transit (starIdx -1), so it is found on state.fleets + // directly rather than fleetsAt(home) — fleetsAt filters by starIdx, + // which this fleet no longer has. + const fleet = st.fleets.find((f) => f.empireIdx === 0 + && f.ships.some((s) => s.hullId === 'poptransport')); + check('a poptransport fleet departs immediately (already in transit)', + !!fleet && fleet.toStar === target && fleet.starIdx < 0); + check('the payload rides on the ship stack, not a fixed per-hull capacity', + Math.abs(fleet.ships[0].popPayload - cap) < 1e-9); + + // Run it to arrival. + const destBefore = second.pop; + let turns = 0; + while (fleet.toStar >= 0 && turns < 200) { Logic.moveFleetsFor(RULES, st, 0); turns += 1; } + check('the transport arrives within a sane number of turns', turns < 200, `${turns}`); + // A near-total transfer from a mature homeworld can comfortably exceed + // a freshly founded colony's max population — delivery caps at that + // max rather than overflowing it, discarding the excess silently + // (a deliberate simplification: the sender's responsibility to check + // the destination has room, exactly like a colony ship arriving at a + // world already at capacity). + const maxPopSecond = Logic.colonyMaxPop(RULES, st, second); + check('arrival delivers the payload, capped at the destination\'s max population', + Math.abs(second.pop - Math.min(maxPopSecond, destBefore + cap)) < 1e-9, + `${second.pop} vs min(${maxPopSecond}, ${destBefore + cap})`); + check('the delivered payload never exceeds the destination\'s max population', + second.pop <= maxPopSecond + 1e-9); + check('the spent transport is cleaned up, not left idle forever', + !Logic.fleetsAt(st, target).some((f) => f.empireIdx === 0 + && f.ships.some((s) => s.hullId === 'poptransport'))); + check('a populationSent event was recorded', + st.events.some((e) => e.type === 'populationSent' && e.empire === 0 && e.starIdx === home)); + check('a populationDelivered event was recorded', + st.events.some((e) => e.type === 'populationDelivered' && e.empire === 0 && e.starIdx === target)); + } + } + + { + // Guardrails: a colony can never be fully emptied, and a request beyond + // what is available is clamped rather than refused outright. + const { st, home, target } = mkTwoColonyGame(4242); + if (target >= 0) { + const source = Logic.colonyAt(st, home); + const cap = Logic.maxSendablePopulation(source); + const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap + 1000); + check('an oversized request is clamped to what the colony can spare', ok); + check('the source is left at exactly the floor', Math.abs(source.pop - 0.5) < 1e-9); + } + } + + { + // Refusals: not your colony, same star, not your destination, nothing to spare. + const { st, home, target } = mkTwoColonyGame(4343); + if (target >= 0) { + const enemyHome = st.galaxy.homeIdx[1]; + check('sending from a colony you do not own is refused', + Logic.sendPopulation(RULES, st, 0, enemyHome, target, 1) === false); + check('sending to a star with no colony of yours is refused', + Logic.sendPopulation(RULES, st, 0, home, st.galaxy.homeIdx[1], 1) === false); + check('sending to the same star is refused', + Logic.sendPopulation(RULES, st, 0, home, home, 1) === false); + const source = Logic.colonyAt(st, home); + source.pop = 0.5; + check('a colony already at the floor has nothing left to send', + Logic.sendPopulation(RULES, st, 0, home, target, 1) === false); + } + } + + { + // A poptransport in transit must never be swept up as invasion troops or + // combat power — it shares no hullId with 'transport' and has no weapons. + const { st, home, target } = mkTwoColonyGame(4444); + if (target >= 0) { + Logic.sendPopulation(RULES, st, 0, home, target, 1); + // Already in transit — see the note above about why this is + // state.fleets directly and not fleetsAt(home). + const fleet = st.fleets.find((f) => f.ships.some((s) => s.hullId === 'poptransport')); + check('invasionForecast ignores poptransport stacks', + Logic.invasionForecast(RULES, st, 1, home)?.troops === 0 + || Logic.invasionForecast(RULES, st, 1, home) === null); + check('poptransport contributes no combat power', + Logic.fleetPower(RULES, st, fleet) === 0); + } + } +} + // --------------------------------------------------------------------------- section('5. Combat'); // ---------------------------------------------------------------------------