From a6b1d3ad2c4498cbfcc4323f6a2cb3b55c96210b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:03:42 +0000 Subject: [PATCH] Blockilization: teammates research their own techs, Civ V style Each teammate now chooses their own research instead of the team sharing one pick that the latest choice overwrote. Progress toward each tech is still the team's: two teammates on the same tech pool their science into it, and on different techs the team researches both at once. A tech finished between them is learned by all, and what's left over goes into the next research of whoever was on it (or waits as spare science). The tech tree shows what each teammate is researching (a tag on the tech and a line in the header), and the research panel says it too. The 'who chose it' state (researchBy), the stale-choice guard and the confirm dialog from the shared-pick design are gone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01K3o1ArEAsnYdwRkyKzWS4X --- src/games/blockilization/client/concepts.ts | 1 + src/games/blockilization/client/techtree.ts | 31 +++---- src/games/blockilization/client/ui.ts | 16 ++-- src/games/blockilization/protocol.ts | 6 +- src/games/blockilization/rules/ai/civics.ts | 4 - src/games/blockilization/rules/game.ts | 87 +++++++------------ src/games/blockilization/rules/state.ts | 2 - src/games/blockilization/tests/cases.ts | 95 ++++++++++++--------- src/games/blockilization/views.ts | 12 ++- 9 files changed, 122 insertions(+), 132 deletions(-) diff --git a/src/games/blockilization/client/concepts.ts b/src/games/blockilization/client/concepts.ts index 7f4f180..2c122f4 100644 --- a/src/games/blockilization/client/concepts.ts +++ b/src/games/blockilization/client/concepts.ts @@ -161,6 +161,7 @@ export const CONCEPTS: Article[] = [ { text: 'Science goes toward one technology at a time. Each needs the ones before it; click any later one in the tree and the way there is queued. Technologies unlock units, buildings, wonders and improvements, and the eras follow from them.' }, { text: 'Each city you have makes technologies cost a little more, so a wide empire needs more science. Libraries, universities and scientists are the way to it; [[unit:great_scientist|Great Scientists]] jump ahead.' }, { title: 'Eurekas', text: 'Nearly every technology has a deed that brings it a third of the way: build a Mine and The Wheel is a third researched, kill a unit and Archery is, found a religion and Theology is. The lightning bolt on each technology in the tree says its deed (point at it), and lights up once you have done it. A deed counts whenever it is done, even long before you can research the technology (its third waits for you there), and on a team, once for all of you.' }, + { title: 'On a team', text: 'Each of you chooses your own research, and the progress is the team’s: research the same technology and both your science goes into it; research different ones and the team works on both at once. A technology any of you learns, all of you know. The tree shows what each teammate is researching. Technologies cost a team more: half again for each civilization past the first.' }, ], }, { diff --git a/src/games/blockilization/client/techtree.ts b/src/games/blockilization/client/techtree.ts index 76cc5dc..e25ad9e 100644 --- a/src/games/blockilization/client/techtree.ts +++ b/src/games/blockilization/client/techtree.ts @@ -3,8 +3,8 @@ import { ERA_NAMES, ERAS, eraOfCol, pathTo, TECH_LIST, TECHS } from '../rules/te import { ERA_COLOR, icon, svg, techEra, techGlyph } from './icons'; import { entryArt, entryName, techUnlocks } from './pedia'; import { adviceChips, adviceTag, researchAdvice, techAdvice } from './advice'; -import { ask, store } from './store'; -import { h, researchChooser, send, tile, togglePanel, type El } from './ui'; +import { store } from './store'; +import { h, matesResearch, send, tile, togglePanel, type El } from './ui'; /** * The technology tree, as Civilization V draws it: the eras across the top, each tech a card in its @@ -194,7 +194,10 @@ export function renderTechTree(): El { const state = known.has(t.id) ? 'known' : s.researching === t.id ? 'now' : queued >= 0 ? 'queued' : can ? 'can' : 'later'; const cost = s.techCosts[t.id] ?? 0; const have = s.research[t.id] ?? 0; - const own = s.researchScience > 0 ? Math.max(1, Math.ceil((cost - have) / s.researchScience)) : null; + // (Researched by teammates too: their science goes into it with what this player would put in.) + const onIt = s.mates.filter((m) => m.researching === t.id); + const rate = s.science + onIt.reduce((n, m) => n + m.science, 0); + const own = rate > 0 ? Math.max(1, Math.ceil((cost - have) / rate)) : null; const meta = known.has(t.id) ? h('span', {}, icon('check', 11, '#6fd27a'), 'Known') : turns.has(t.id) @@ -240,6 +243,7 @@ export function renderTechTree(): El { ), ), queued >= 0 && h('span.tt-badge', {}, String(queued + 1)), + onIt.length > 0 && h('span.tt-mate', { title: `Your team: ${onIt.map((m) => v.players[m.index]?.name).join(' and ')} researching it` }, onIt.map((m) => v.players[m.index]?.name ?? 'Teammate').join(', ')), have > 0 && !known.has(t.id) && h('div.tt-bar', {}, h('i', { style: `width:${Math.min(100, Math.round((have / Math.max(1, cost)) * 100))}%` })), ); }); @@ -273,7 +277,7 @@ export function renderTechTree(): El { 'div.tt-top', {}, tile('science', '#2f6fc0', 40), - h('div', {}, h('div.cv-h.big', {}, 'Technology'), h('small.cv-muted', {}, `${s.science > 0 ? `+${s.science} science a turn` : 'No science yet: found a city'}${s.researchScience !== s.science ? ` (your team’s together: +${s.researchScience}, researching as one${s.researchBy !== null ? `; ${researchChooser()}` : ''})` : ''} · ${s.techs.length} known`)), + h('div', {}, h('div.cv-h.big', {}, 'Technology'), h('small.cv-muted', {}, `${s.science > 0 ? `+${s.science} science a turn` : 'No science yet: found a city'}${s.researchScience !== s.science ? ` (+${s.researchScience} with your teammates on it)` : ''} · ${s.techs.length} known${s.mates.length ? ` · Team: ${matesResearch()}` : ''}`)), queue, h('div.tt-find', {}, icon('lens', 15, '#8fa1b3'), search, h('small.tt-found.cv-muted')), ), @@ -310,21 +314,13 @@ export function renderTechTree(): El { /** * Research chosen: with nothing being researched before (what the turn was waiting on), the tree - * closes and it's back to the map; choosing another later, it stays open. On a team, a teammate's - * choice is the whole team's: it's replaced only on a yes, and the order says what was seen (one - * a teammate made meanwhile isn't undone unseen, `research` in `rules/game.ts`). + * closes and it's back to the map; choosing another later, it stays open. (On a team, each chooses + * their own: a teammate's research isn't changed by it.) */ function chooseResearch(tech: string) { - const v = store.view!; - const was = v.self.researching; - const go = () => { - send({ t: 'research', tech, was }); - if (!was && store.panel === 'tech') togglePanel('tech'); - }; - const by = v.self.researchBy; - const mate = by !== null && by !== v.you ? v.players[by] : undefined; - if (was && was !== tech && mate && !mate.bot) ask(`${mate.name} chose ${TECHS[was].name} for the team. Research ${TECHS[tech].name} instead?`, 'Change research', go, false); - else go(); + const first = !store.view?.self.researching; + send({ t: 'research', tech }); + if (first && store.panel === 'tech') togglePanel('tech'); } function scrollTo(scroller: El, id: string) { @@ -424,6 +420,7 @@ export const TECH_CSS = ` .tt-card.later .n { color: #c3ccd6; } .tt-card.now { background: #2a2416 !important; border-color: var(--gold) !important; box-shadow: 0 0 0 3px rgba(242,193,78,.2), 0 6px 18px rgba(0,0,0,.4); } .tt-card.queued { background: #221f17 !important; border-color: rgba(242,193,78,.55) !important; } +.tt-mate { position: absolute; top: -8px; left: 10px; max-width: calc(100% - 40px); height: 16px; padding: 0 7px; border-radius: 99px; background: #2f6fc0; color: #fff; font-size: 10px; font-weight: 800; line-height: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 2px 6px rgba(0,0,0,.4); } .tt-badge { position: absolute; top: -7px; right: -7px; min-width: 20px; height: 20px; padding: 0 5px; border-radius: 99px; background: linear-gradient(180deg, #f6cf62, #dca23a); color: #1b1407; font-size: 11px; font-weight: 800; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,.4); } .tt-bar { position: absolute; left: 10px; right: 10px; bottom: 4px; height: 3px; border-radius: 2px; background: rgba(255,255,255,.08); overflow: hidden; } .tt-bar i { display: block; height: 100%; background: linear-gradient(90deg, #4a9ae8, #7fc4ff); } diff --git a/src/games/blockilization/client/ui.ts b/src/games/blockilization/client/ui.ts index 3f66cea..a1018c3 100644 --- a/src/games/blockilization/client/ui.ts +++ b/src/games/blockilization/client/ui.ts @@ -874,17 +874,21 @@ function renderResearch(): El { h('div.cv-bar', {}, h('i', { style: `width:${t ? Math.round((have / cost) * 100) : 0}%` })), !t && h('small.cv-muted', {}, `${click(true)} to open the tech tree`), !t && researchHint() && h('div.adv-hint', {}, icon('lightbulb', 11, '#ffe48a'), researchHint()), - // (On a team: the team's research, and whose choice it is.) - t && s.researchBy !== null && h('small.cv-muted', {}, `Team research · ${researchChooser()}`), + // (On a team: what each teammate is researching.) + s.mates.length > 0 && h('small.cv-muted', {}, `Team · ${matesResearch()}`), ), ); } -/** Who chose a team's research, in words ('chosen by Bo', 'your choice'). */ -export function researchChooser(): string { +/** On a team, what each teammate is researching, in words ('Bo on it too', 'Bo: Pottery'). */ +export function matesResearch(): string { const v = store.view!; - const by = v.self.researchBy; - return by === v.you ? 'your choice' : `chosen by ${v.players[by ?? -1]?.name ?? 'your team'}`; + return v.self.mates + .map((m) => { + const name = v.players[m.index]?.name ?? 'A teammate'; + return !m.researching ? `${name}: nothing yet` : m.researching === v.self.researching ? `${name} on it too` : `${name}: ${TECHS[m.researching]?.name ?? m.researching}`; + }) + .join(' · '); } /** '2 units need orders'. */ diff --git a/src/games/blockilization/protocol.ts b/src/games/blockilization/protocol.ts index 96bbd82..dcc2f49 100644 --- a/src/games/blockilization/protocol.ts +++ b/src/games/blockilization/protocol.ts @@ -332,10 +332,10 @@ export interface SelfWire { goldenTurns: number; techs: string[]; researching: string | null; - /** Science toward research a turn: the viewer's, or (on a team) the whole team's. */ + /** Science toward research a turn: the viewer's, and (on a team) that of each teammate researching the same tech. */ researchScience: number; - /** On a team: which of them chose what's researched (null: alone, or nobody yet). */ - researchBy: number | null; + /** On a team: what each teammate is researching (each chooses their own; what they put in is the team's). */ + mates: { index: number; researching: string | null; science: number }[]; research: Record; techQueue: string[]; /** The techs whose eureka the viewer (or their team) has had (`rules/eurekas.ts`). */ diff --git a/src/games/blockilization/rules/ai/civics.ts b/src/games/blockilization/rules/ai/civics.ts index fdce4ec..1ae82c2 100644 --- a/src/games/blockilization/rules/ai/civics.ts +++ b/src/games/blockilization/rules/ai/civics.ts @@ -5,7 +5,6 @@ import { dissent, policyCost } from '../economy'; import { canChooseIdeology, eraOf, ideologySwitch, reformationDue, SWITCH_DISSENT } from '../game'; import { adoptable, ALL_POLICIES, IDEOLOGIES, TREES, treeOpen, type Policy } from '../policies'; import { BELIEF_LIST, pantheonCost, takenBeliefs, type BeliefKind } from '../religion'; -import { teamOf } from '../teams'; import { canResearch, pathTo, TECH_LIST, TECHS } from '../techs'; import { IMPROVEMENTS, RESOURCES } from '../terrain'; import { isMilitary, UNIT_LIST, UNITS } from '../units'; @@ -65,9 +64,6 @@ function techValue(ctx: Ctx, id: string, resources: Set): number { /** Research: toward the goal with the best worth for its cost; a new goal when that's reached. */ export function research(ctx: Ctx) { const { game, p, mem } = ctx; - // A team researches together: a person's choice stands; on a team of bots, the first of them chooses. - const team = teamOf(game, ctx.me); - if (team.length > 1 && p.researching && (team.some((m) => !m.bot) || team.find((m) => m.bot) !== p)) return; const known = new Set(p.techs); if (p.researching && mem.techGoal && !known.has(mem.techGoal) && (p.techQueue.length || p.researching === mem.techGoal)) return; const resources = new Set(); diff --git a/src/games/blockilization/rules/game.ts b/src/games/blockilization/rules/game.ts index 7e71fb2..a996aec 100644 --- a/src/games/blockilization/rules/game.ts +++ b/src/games/blockilization/rules/game.ts @@ -70,8 +70,7 @@ export type Order = | { t: 'work'; city: number; tile: number } | { t: 'specialist'; city: number; kind: SpecialistKind; add: boolean } | { t: 'cityFire'; city: number; target: number } - /** `was`: the research the one choosing saw (on a team, a teammate's choice made since stands, not undone unseen). */ - | { t: 'research'; tech: string; was?: string | null } + | { t: 'research'; tech: string } | { t: 'policy'; id: string } | { t: 'ideology'; id: string } | { t: 'switchIdeology'; id: string } @@ -600,10 +599,11 @@ export function learn(game: Game, p: Player, id: string) { } /** - * A team researches together, Civilization V's way: one research for the team, chosen by any of them - * (`research` order; the latest choice holds); the science each puts in, pooled toward it (a tech - * costs a team more, `techCost`); a tech learned, by any of them or any way, learned by all. Each - * keeps the team's research as their own: made the same again here, after every order and as the + * A team researches together, Civilization V's way: each of them chooses their own research + * (`research` order), and the science each puts into a tech is the team's progress toward it (a tech + * costs a team more, `techCost`): two of them on one tech finish it sooner, on two different ones + * they research both at once. A tech learned, by any of them or any way, is learned by all. Each + * keeps the team's progress as their own: made the same again here, after every order and as the * turn ends (what each has put in since the last time, added up). */ export function shareResearch(game: Game) { @@ -627,34 +627,25 @@ export function shareResearch(game: Game) { const sum = team.reduce((n, m) => n + (m.research[id] ?? 0) - was, was); if (sum > 0) pooled[id] = sum; } - // What they research: the choice of whoever made it last, else a person's, else anyone's. - const chooser = team.find((m) => m.index === p.researchBy && m.researching) ?? team.find((m) => !m.bot && m.researching) ?? team.find((m) => m.researching) ?? p; - let researching = chooser.researching; - let queue = [...chooser.techQueue]; - // Finished between them: learned by all, what's over going on. - while (researching && (pooled[researching] ?? 0) >= techCost(game, p, researching)) { - const over = pooled[researching] - techCost(game, p, researching); - delete pooled[researching]; - for (const m of team) { - m.researching = researching; - m.techQueue = [...queue]; - learn(game, m, researching); - } - researching = p.researching; - queue = [...p.techQueue]; - if (researching) pooled[researching] = (pooled[researching] ?? 0) + over; - else { - // (Nothing next: what's over waits for the next choice, as it does for a civ alone.) - p.spareScience = (p.spareScience ?? 0) + over; - for (const m of team) if (!m.bot) note(game, m.index, { text: 'Choose what to research next.', kind: 'tech', remind: true }); - } - } - for (const m of team) { - m.research = { ...pooled }; - m.researching = researching; - m.techQueue = [...queue]; - m.researchBy = chooser.index; + // Finished between them: learned by all; what's over goes on into the next research of those + // who were on it (shared out), or waits for their next choice, as it does for a civ alone. + const finished = () => Object.keys(pooled).find((id) => pooled[id] >= techCost(game, p, id)); + for (let id = finished(); id; id = finished()) { + const over = pooled[id] - techCost(game, p, id); + delete pooled[id]; + const on = team.filter((m) => m.researching === id); + for (const m of team) learn(game, m, id); + const to = on.length ? on : [p]; + to.forEach((m, i) => { + const share = Math.floor(over / to.length) + (i === 0 ? over - Math.floor(over / to.length) * to.length : 0); + if (m.researching) pooled[m.researching] = (pooled[m.researching] ?? 0) + share; + else { + m.spareScience = (m.spareScience ?? 0) + share; + if (!m.bot && on.includes(m)) note(game, m.index, { text: 'Choose what to research next.', kind: 'tech', remind: true }); + } + }); } + for (const m of team) m.research = { ...pooled }; game.teamResearch[p.team] = { ...pooled }; } } @@ -847,7 +838,7 @@ export function workTurns(game: Game, what: string): number { } /** What an order's field must be: a whole number, a tile of the map, a whole number or nothing, a string, one of some strings (or nothing, null or left out, where null is one), a list of strings, a list (checked where it's used), a yes or no, a production item; `?` when it may be left out. */ -type Field = 'int' | 'tile' | 'int|null' | 'string' | 'string?' | 'strings' | 'list' | 'bool' | 'bool?' | 'item' | readonly (string | null)[]; +type Field = 'int' | 'tile' | 'int|null' | 'string' | 'strings' | 'list' | 'bool' | 'bool?' | 'item' | readonly (string | null)[]; /** The luxuries' ids (a resolution banning one names it). */ const LUXURY_IDS = Object.keys(RESOURCES).filter((r) => RESOURCES[r].kind === 'luxury'); @@ -884,7 +875,7 @@ const SHAPES: { [K in Order['t']]: { [F in Exclude typeof x === 'string'); case 'list': @@ -1404,16 +1393,11 @@ function applyOrder(game: Game, by: number, o: Order): Outcome { } case 'research': { if (!Object.hasOwn(TECHS, o.tech)) return fail('No such tech'); - // On a team, the research is the team's (any of them may set it), but a bot doesn't undo a person's choice. - const chooser = p.researchBy !== undefined ? game.players[p.researchBy] : undefined; - if (p.bot && p.researching && chooser && !chooser.bot && chooser !== p) return fail("The team's people choose its research"); - // Two of them choosing at once: whoever was first keeps theirs, and the other's told (and can choose again, seeing it). - if (o.was !== undefined && p.researching && o.was !== p.researching && chooser && !chooser.bot && chooser !== p) return fail(`${chooser.name} just chose ${TECHS[p.researching].name} for the team`); + // (On a team, each chooses their own: what they put in is the team's, `shareResearch`.) if (canResearch(new Set(p.techs), o.tech)) { - for (const m of teamOf(game, by)) m.researchBy = by; p.researching = o.tech; p.techQueue = []; - spendSpare(game, p); + spendSpare(p); return OK; } // A far tech: queue the way there. @@ -1426,10 +1410,9 @@ function applyOrder(game: Game, by: number, o: Order): Outcome { }; visit(o.tech); if (!order.length) return fail('Already known'); - for (const m of teamOf(game, by)) m.researchBy = by; p.researching = order[0]; p.techQueue = order.slice(1); - spendSpare(game, p); + spendSpare(p); return OK; } case 'policy': { @@ -1727,14 +1710,10 @@ function bought(c: City, item: Production) { } /** Science kept with nothing to research goes into what's chosen. */ -function spendSpare(game: Game, p: Player) { - if (!p.researching) return; - // (On a team, what any of them has spare: theirs is the team's research too, `shareResearch`.) - for (const m of teamOf(game, p.index)) { - if (!m.spareScience) continue; - p.research[p.researching] = (p.research[p.researching] ?? 0) + m.spareScience; - m.spareScience = undefined; - } +function spendSpare(p: Player) { + if (!p.spareScience || !p.researching) return; + p.research[p.researching] = (p.research[p.researching] ?? 0) + p.spareScience; + p.spareScience = undefined; } /** Keep the production toward what's being built now (it's being put aside). */ diff --git a/src/games/blockilization/rules/state.ts b/src/games/blockilization/rules/state.ts index f9360b2..7ff395a 100644 --- a/src/games/blockilization/rules/state.ts +++ b/src/games/blockilization/rules/state.ts @@ -220,8 +220,6 @@ export interface Player { techQueue: string[]; /** Science with no tech to go to (one just learned with none chosen next): it goes into the next one chosen. */ spareScience?: number; - /** Who chose what's being researched (on a team, which of them: `shareResearch`). */ - researchBy?: number; /** The techs they've had their eureka for (a third of the research, for a deed: `eurekas.ts`), and the deeds counted for them (kills, by what killed). */ eurekas?: string[]; deeds?: Record; diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index b039ae7..93ca872 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -3398,60 +3398,66 @@ test('teams: a team wins together (conquest: holding every capital between them; ok([0, 1].includes(late.over?.winner ?? -1), `the team wins on score (${JSON.stringify(late.over)})`); }); -test('team research: one research for the team, set by any of them; a bot doesn’t undo a person’s choice', () => { +test('team research: each of them chooses their own (Civ V’s): a teammate’s choice changes nobody else’s', () => { const { game, at } = blank(['egypt', 'babylon', 'germany']); teamUp(game, [0, 1]); [at(3, 3), at(14, 3), at(22, 15)].forEach((t, i) => city(game, i, t)); const [a, b] = game.players; for (const p of game.players) p.techs.push('agriculture'); - a.bot = false; - ok(apply(game, 0, { t: 'research', tech: 'pottery' }).ok, 'a person chooses'); - eq(b.researching, 'pottery', 'the teammate researches it too'); - eq(b.researchBy, 0, 'and knows whose choice it is'); - ok(!apply(game, 1, { t: 'research', tech: 'mining' }).ok, 'a bot on the team doesn’t undo it'); - eq(a.researching, 'pottery', 'still pottery'); + a.bot = b.bot = false; + ok(apply(game, 0, { t: 'research', tech: 'pottery' }).ok, 'one chooses'); + eq(b.researching, null, 'the other’s isn’t chosen for them'); + ok(apply(game, 1, { t: 'research', tech: 'mining' }).ok, 'the other chooses another'); + eq(a.researching, 'pottery', 'the first one’s stands'); + eq(b.researching, 'mining', 'and the second one’s'); ok(apply(game, 0, { t: 'research', tech: 'writing' }).ok, 'a far tech'); - eq(b.researching, 'pottery', 'its way there, for both'); - ok(b.techQueue.includes('writing'), 'queued for both'); - ok(apply(game, 2, { t: 'research', tech: 'mining' }).ok && game.players[2].researching === 'mining', 'a civ alone is its own master'); + ok(a.researching === 'pottery' && a.techQueue.includes('writing'), 'its way there, queued'); + ok(b.researching === 'mining' && !b.techQueue.length, 'for them alone'); + b.bot = true; + ok(apply(game, 1, { t: 'research', tech: 'animal_husbandry' }).ok && a.researching === 'pottery', 'a bot teammate chooses its own too'); + ok(apply(game, 2, { t: 'research', tech: 'mining' }).ok && game.players[2].researching === 'mining', 'a civ alone as ever'); }); -test('team research: two people choosing at once, the first keeps theirs (the second is told, and can choose again)', () => { +test('team research: on different techs, a team researches both at once; on the same one, both put into it', () => { const { game, at } = blank(['egypt', 'babylon', 'germany']); teamUp(game, [0, 1]); [at(3, 3), at(14, 3), at(22, 15)].forEach((t, i) => city(game, i, t)); const [a, b] = game.players; - for (const p of game.players) p.techs.push('agriculture'); + // (Eurekas aside: they'd be science too.) + for (const p of game.players) (p.techs.push('agriculture'), (p.eurekas = Object.keys(EUREKAS))); a.bot = b.bot = false; - // Both see nothing chosen; the first's order lands first. - ok(apply(game, 0, { t: 'research', tech: 'pottery', was: null }).ok, 'the first chooses'); - const late = apply(game, 1, { t: 'research', tech: 'mining', was: null }); - ok(!late.ok && /just chose Pottery/.test(late.error ?? ''), `the second's, from what they saw before it, is turned back (${late.error})`); - eq(a.researching, 'pottery', 'still the first one’s'); - eq(b.researchBy, 0, 'and theirs it says'); - // Seeing it, the other may change it. - ok(apply(game, 1, { t: 'research', tech: 'mining', was: 'pottery' }).ok, 'seeing it, changed on purpose'); - eq(a.researching, 'mining', 'for both'); - eq(a.researchBy, 1, 'the choice now the second one’s'); - ok(apply(game, 1, { t: 'research', tech: 'mining', was: 'pottery' }).ok, 'their own choice they may change as they like'); - ok(!apply(game, 0, { t: 'research', tech: 'agriculture' }).ok && a.researchBy === 1, 'a choice turned back changes nobody’s'); -}); - -test('team research: a turn with nothing chosen, the team’s science waits for the choice (as a civ alone’s does)', () => { + const science = (i: number) => game.cities.filter((c) => c.owner === i).reduce((n, c) => n + cityYields(game, c).science, 0); + ok(science(0) > 0 && science(1) > 0, 'both make science'); + apply(game, 0, { t: 'research', tech: 'pottery' }); + apply(game, 1, { t: 'research', tech: 'mining' }); + endTurn(game); + eq(a.research.pottery, science(0), 'the one’s science into theirs'); + eq(a.research.mining, science(1), 'the other’s into theirs'); + eq(JSON.stringify(b.research), JSON.stringify(a.research), 'the progress the team’s: the same for both'); + ok(a.researching === 'pottery' && b.researching === 'mining', 'each still on their own'); + // The other joins in: both into one. + apply(game, 1, { t: 'research', tech: 'pottery' }); + const before = a.research.pottery; + endTurn(game); + eq(a.research.pottery, before + science(0) + science(1), `a turn's science from both (${science(0)} + ${science(1)})`); + eq(a.research.mining, science(1), 'what went into the other is kept'); +}); + +test('team research: a turn with nothing chosen, each one’s science waits for their own choice (as a civ alone’s does)', () => { const { game, at } = blank(['egypt', 'babylon', 'germany']); teamUp(game, [0, 1]); [at(3, 3), at(14, 3), at(22, 15)].forEach((t, i) => city(game, i, t)); const [a, b] = game.players; - for (const p of game.players) p.techs.push('agriculture'); + for (const p of game.players) (p.techs.push('agriculture'), (p.eurekas = Object.keys(EUREKAS))); a.bot = b.bot = false; const science = (i: number) => game.cities.filter((c) => c.owner === i).reduce((n, c) => n + cityYields(game, c).science, 0); - const both = science(0) + science(1); - ok(both > 0, 'they make science'); endTurn(game); - eq(a.researching, null, 'nothing chosen'); + ok(a.researching === null && b.researching === null, 'nothing chosen'); ok(apply(game, 1, { t: 'research', tech: 'pottery' }).ok, 'then one chooses'); - eq(a.research.pottery, both, `what both made meanwhile goes into it (${science(0)} + ${science(1)})`); - eq(b.research.pottery, both, 'for both'); + eq(a.research.pottery, science(1), 'what they made meanwhile goes into it, the team’s'); + eq(a.spareScience, science(0), 'the other’s waits for their own choice'); + ok(apply(game, 0, { t: 'research', tech: 'mining' }).ok, 'which they make'); + eq(b.research.mining, science(0), 'and theirs goes into it'); }); test('teams: a bot on a team with a person sells no peace for the team (its people make it)', () => { @@ -3472,27 +3478,32 @@ test('team research: what each puts in is pooled; finished between them, it’s const [a, b] = game.players; // (Eurekas aside: Writing's, for having met each other, would be science too.) for (const p of game.players) (p.techs.push('agriculture'), (p.eurekas = Object.keys(EUREKAS))); + a.bot = b.bot = false; apply(game, 0, { t: 'research', tech: 'pottery' }); + apply(game, 1, { t: 'research', tech: 'pottery' }); const cost = techCost(game, a, 'pottery'); a.research.pottery = Math.ceil(cost * 0.3); b.research.pottery = Math.ceil(cost * 0.3); shareResearch(game); eq(a.research.pottery, b.research.pottery, 'the same for both'); eq(a.research.pottery, Math.ceil(cost * 0.3) * 2, 'what each put in, together'); + // Each with their own next. a.techQueue = ['writing']; - b.techQueue = ['writing']; + b.techQueue = ['mining']; a.research.pottery += Math.ceil(cost * 0.3); b.research.pottery += Math.ceil(cost * 0.3); shareResearch(game); ok(a.techs.includes('pottery') && b.techs.includes('pottery'), 'learned by both'); - eq(a.researching, 'writing', 'on to the next'); - eq(a.research.writing, Math.ceil(cost * 0.3) * 4 - cost, 'what was over goes on'); - eq(b.research.writing, a.research.writing, 'for both'); - // A turn: each one's science goes in, and the team's together is what counts. - const before = a.research.writing; - const science = (i: number) => game.cities.filter((c) => c.owner === i).reduce((n, c) => n + cityYields(game, c).science, 0); - endTurn(game); - eq(a.research.writing, before + science(0) + science(1), `a turn's science from both (${science(0)} + ${science(1)})`); + ok(a.researching === 'writing' && b.researching === 'mining', 'each on to their own next'); + eq((a.research.writing ?? 0) + (a.research.mining ?? 0), Math.ceil(cost * 0.3) * 4 - cost, 'what was over goes on, shared between them'); + eq(JSON.stringify(b.research), JSON.stringify(a.research), 'the same for both'); + // One finishing theirs: the other's goes on as it was. + const mining = a.research.mining ?? 0; + a.research.writing = techCost(game, a, 'writing'); + shareResearch(game); + ok(b.techs.includes('writing'), 'writing learned by both'); + eq(b.researching, 'mining', 'the other still on theirs'); + eq(b.research.mining, mining, 'with what was in it'); }); test('team research: a tech learned by one, any way, is learned by all; a team’s techs cost more', () => { diff --git a/src/games/blockilization/views.ts b/src/games/blockilization/views.ts index d79de97..e7460af 100644 --- a/src/games/blockilization/views.ts +++ b/src/games/blockilization/views.ts @@ -111,9 +111,13 @@ function selfWire(game: Game, p: Player): SelfWire { gold += y.gold; } const up = goldUpkeep(game, p); - // On a team, research is the team's: the science of all of them goes into it (`shareResearch`). - const mates = p.team === undefined ? [] : teamOf(game, p.index).filter((m) => m !== p); - const teamScience = science + mates.reduce((n, m) => n + citiesOf(game, m.index).reduce((s, c) => s + cityYields(game, c, happiness(game, m)).science, 0), 0); + // On a team, each researches their own, the progress the team's (`shareResearch`): a teammate on the same tech adds theirs to it. + const mates = (p.team === undefined ? [] : teamOf(game, p.index).filter((m) => m !== p)).map((m) => ({ + index: m.index, + researching: m.researching, + science: citiesOf(game, m.index).reduce((s, c) => s + cityYields(game, c, happiness(game, m)).science, 0), + })); + const teamScience = science + mates.reduce((n, m) => n + (p.researching && m.researching === p.researching ? m.science : 0), 0); const techCosts: Record = {}; for (const id of Object.keys(TECHS)) techCosts[id] = techCost(game, p, id); // (A vassal pays its master a share of what it makes; a master gets its vassals' tribute: `vassals.ts`.) @@ -141,7 +145,7 @@ function selfWire(game: Game, p: Player): SelfWire { techs: p.techs, researching: p.researching, researchScience: teamScience, - researchBy: mates.length && p.researchBy !== undefined ? p.researchBy : null, + mates, research: p.research, techQueue: p.techQueue, eurekas: p.eurekas ?? [],