Skip to content

Commit 17f301e

Browse files
YfengJleocagli
andauthored
Add Civ Lite barbarian camps and events (#61)
* Add Civ Lite barbarian camps and events * Address Sonar feedback for barbarian events * Clear barbarian model Sonar warning --------- Co-authored-by: leocagli <cosmosapplat@gmail.com>
1 parent c1aa2b8 commit 17f301e

5 files changed

Lines changed: 598 additions & 10 deletions

File tree

demo/civ-lite/barbarian-model.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
const EVENT_CADENCE = 4;
2+
3+
const EVENT_TABLE = [
4+
{
5+
id: 'ancient_ruins',
6+
title: 'Ancient ruins',
7+
description: 'Scouts uncover a buried archive near your border.',
8+
choices: [
9+
{ id: 'study', label: 'Study the tablets', effects: { science: 5, cityProd: 1 } },
10+
{ id: 'salvage', label: 'Salvage the stonework', effects: { prod: 4, cityProd: 2 } },
11+
],
12+
},
13+
{
14+
id: 'bumper_harvest',
15+
title: 'Bumper harvest',
16+
description: 'A mild season gives your capital surplus grain.',
17+
choices: [
18+
{ id: 'store', label: 'Store grain', effects: { food: 4, cityFood: 4 } },
19+
{ id: 'trade', label: 'Trade surplus', effects: { prod: 2, science: 2 } },
20+
],
21+
},
22+
{
23+
id: 'frontier_plague',
24+
title: 'Frontier sickness',
25+
description: 'A caravan brings illness and rumors from the frontier.',
26+
choices: [
27+
{ id: 'quarantine', label: 'Quarantine quickly', effects: { prod: -1, cityFood: 2 } },
28+
{ id: 'research', label: 'Fund healers', effects: { science: 4, food: -1 } },
29+
],
30+
},
31+
];
32+
33+
function hashSeed(seed) {
34+
let hash = 2166136261;
35+
for (const char of String(seed)) {
36+
hash ^= char.codePointAt(0);
37+
hash = Math.imul(hash, 16777619);
38+
}
39+
return hash >>> 0;
40+
}
41+
42+
export function createSeededRng(seed) {
43+
let state = hashSeed(seed) || 1;
44+
return () => {
45+
state = Math.imul(state, 1664525) + 1013904223;
46+
return (state >>> 0) / 4294967296;
47+
};
48+
}
49+
50+
function distance(a, b) {
51+
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
52+
}
53+
54+
function isSafeTile(tile, safeZones, minDistance) {
55+
return safeZones.every(zone => distance(tile, zone) > minDistance);
56+
}
57+
58+
export function planBarbarianCamps({
59+
map,
60+
seed,
61+
count = 3,
62+
safeZones = [],
63+
minDistance = 3,
64+
}) {
65+
const rng = createSeededRng(seed);
66+
const candidates = [];
67+
68+
for (let y = 0; y < map.length; y++) {
69+
for (let x = 0; x < map[y].length; x++) {
70+
const tile = { x, y };
71+
if (map[y][x] !== 'water' && isSafeTile(tile, safeZones, minDistance)) {
72+
candidates.push({ ...tile, score: rng() });
73+
}
74+
}
75+
}
76+
77+
const sortedCandidates = [...candidates];
78+
sortedCandidates.sort((a, b) => a.score - b.score);
79+
80+
return sortedCandidates
81+
.slice(0, count)
82+
.map((tile, index) => ({
83+
id: `camp-${index + 1}`,
84+
name: `Camp ${index + 1}`,
85+
x: tile.x,
86+
y: tile.y,
87+
hp: 40,
88+
lastSpawnTurn: 1,
89+
cleared: false,
90+
}));
91+
}
92+
93+
export function shouldSpawnFromCamp(camp, turn, cadence = 3) {
94+
return !camp.cleared && turn - camp.lastSpawnTurn >= cadence;
95+
}
96+
97+
export function createBarbarianUnit(camp, id, turn) {
98+
return {
99+
id,
100+
owner: 'barbarian',
101+
x: camp.x,
102+
y: camp.y,
103+
hp: 70,
104+
atk: 22,
105+
def: 10,
106+
mov: 1,
107+
movLeft: 1,
108+
type: 'raider',
109+
campId: camp.id,
110+
spawnedTurn: turn,
111+
};
112+
}
113+
114+
export function buildRandomEvent({ seed, turn, cadence = EVENT_CADENCE }) {
115+
if (turn <= 1 || turn % cadence !== 0) return null;
116+
const rng = createSeededRng(`${seed}:${turn}`);
117+
const event = EVENT_TABLE[Math.floor(rng() * EVENT_TABLE.length)];
118+
119+
return {
120+
id: event.id,
121+
title: event.title,
122+
description: event.description,
123+
turn,
124+
choices: event.choices.map(choice => ({
125+
id: choice.id,
126+
label: choice.label,
127+
effects: { ...choice.effects },
128+
})),
129+
};
130+
}
131+
132+
export function applyEventChoice(empire, event, choiceId) {
133+
const choice = event.choices.find(item => item.id === choiceId);
134+
if (!choice) return { ...empire };
135+
136+
const next = { ...empire };
137+
for (const [key, value] of Object.entries(choice.effects)) {
138+
next[key] = (next[key] ?? 0) + value;
139+
}
140+
return next;
141+
}
142+
143+
export function formatEventEffects(effects) {
144+
return Object.entries(effects)
145+
.map(([key, value]) => `${value > 0 ? '+' : ''}${value} ${key}`)
146+
.join(', ');
147+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import {
5+
applyEventChoice,
6+
buildRandomEvent,
7+
createBarbarianUnit,
8+
planBarbarianCamps,
9+
shouldSpawnFromCamp,
10+
} from './barbarian-model.js';
11+
12+
const map = [
13+
['plains', 'forest', 'hill', 'plains', 'desert', 'plains'],
14+
['plains', 'water', 'plains', 'forest', 'hill', 'plains'],
15+
['hill', 'plains', 'desert', 'plains', 'forest', 'plains'],
16+
['plains', 'forest', 'plains', 'hill', 'plains', 'desert'],
17+
['desert', 'plains', 'hill', 'plains', 'water', 'plains'],
18+
['plains', 'hill', 'forest', 'plains', 'desert', 'plains'],
19+
];
20+
21+
test('places deterministic barbarian camps on valid land away from capitals', () => {
22+
const first = planBarbarianCamps({
23+
map,
24+
seed: 'barbarians-21',
25+
count: 3,
26+
safeZones: [{ x: 0, y: 0 }, { x: 5, y: 5 }],
27+
});
28+
const again = planBarbarianCamps({
29+
map,
30+
seed: 'barbarians-21',
31+
count: 3,
32+
safeZones: [{ x: 0, y: 0 }, { x: 5, y: 5 }],
33+
});
34+
35+
assert.deepEqual(first, again);
36+
assert.equal(first.length, 3);
37+
for (const camp of first) {
38+
assert.notEqual(map[camp.y][camp.x], 'water');
39+
assert.ok(Math.abs(camp.x - 0) + Math.abs(camp.y - 0) > 3);
40+
assert.ok(Math.abs(camp.x - 5) + Math.abs(camp.y - 5) > 3);
41+
assert.match(camp.name, /^Camp /);
42+
}
43+
});
44+
45+
test('creates reproducible hostile units and throttles camp spawns by cadence', () => {
46+
const camp = { id: 'camp-1', x: 3, y: 2, lastSpawnTurn: 1 };
47+
48+
assert.equal(shouldSpawnFromCamp(camp, 2, 3), false);
49+
assert.equal(shouldSpawnFromCamp(camp, 4, 3), true);
50+
51+
const unit = createBarbarianUnit(camp, 12, 5);
52+
assert.deepEqual(unit, {
53+
id: 12,
54+
owner: 'barbarian',
55+
x: 3,
56+
y: 2,
57+
hp: 70,
58+
atk: 22,
59+
def: 10,
60+
mov: 1,
61+
movLeft: 1,
62+
type: 'raider',
63+
campId: 'camp-1',
64+
spawnedTurn: 5,
65+
});
66+
});
67+
68+
test('selects deterministic random events only on event turns', () => {
69+
assert.equal(buildRandomEvent({ seed: 'events', turn: 3 }), null);
70+
71+
const event = buildRandomEvent({ seed: 'events', turn: 4 });
72+
const again = buildRandomEvent({ seed: 'events', turn: 4 });
73+
74+
assert.deepEqual(event, again);
75+
assert.ok(event.id);
76+
assert.equal(event.turn, 4);
77+
assert.ok(event.choices.length >= 2);
78+
assert.ok(event.choices.every(choice => choice.id && choice.label));
79+
});
80+
81+
test('applies event choices without mutating the original empire snapshot', () => {
82+
const empire = {
83+
food: 2,
84+
prod: 3,
85+
science: 1,
86+
cityFood: 4,
87+
cityProd: 5,
88+
};
89+
const event = {
90+
id: 'ancient_ruins',
91+
choices: [
92+
{ id: 'study', effects: { science: 5, cityProd: 1 } },
93+
{ id: 'salvage', effects: { prod: 4 } },
94+
],
95+
};
96+
97+
const result = applyEventChoice(empire, event, 'study');
98+
99+
assert.deepEqual(empire, {
100+
food: 2,
101+
prod: 3,
102+
science: 1,
103+
cityFood: 4,
104+
cityProd: 5,
105+
});
106+
assert.deepEqual(result, {
107+
food: 2,
108+
prod: 3,
109+
science: 6,
110+
cityFood: 4,
111+
cityProd: 6,
112+
});
113+
});

0 commit comments

Comments
 (0)